Keys и schema

В библиотеке Joi объектная валидация строится вокруг схем. Любая структура данных описывается через специальные schema-объекты, а ключевую роль в работе со сложными объектами играют методы keys() и механизм schema-композиции.

keys() используется для описания структуры объекта, определения допустимых полей, их типов, ограничений и поведения при валидации.

const Joi = require('joi');

const schema = Joi.object().keys({
    username: Joi.string().min(3).max(30).required(),
    age: Joi.number().integer().min(18),
    email: Joi.string().email()
});

В этом примере:

  • username — обязательная строка;
  • age — целое число не меньше 18;
  • email — строка в формате email.

Метод keys()

Базовая структура

keys() принимает объект, где:

  • ключ — имя свойства;
  • значение — схема Joi.
const userSchema = Joi.object().keys({
    id: Joi.number(),
    name: Joi.string(),
    active: Joi.boolean()
});

Аналогичная запись:

const userSchema = Joi.object({
    id: Joi.number(),
    name: Joi.string(),
    active: Joi.boolean()
});

Обе формы равнозначны.


Валидация объектов

Пример проверки

const schema = Joi.object({
    title: Joi.string().required(),
    price: Joi.number().positive().required()
});

const result = schema.validate({
    title: 'Ноутбук',
    price: 500
});

console.log(result.error);

Если данные корректны:

null

Если есть ошибка:

"price" must be a positive number

Обязательные и необязательные ключи

required()

const schema = Joi.object({
    login: Joi.string().required(),
    password: Joi.string().required()
});

Без обязательного поля:

{
    login: 'admin'
}

Ошибка:

"password" is required

optional()

Все поля по умолчанию необязательные.

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

forbidden()

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

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

При передаче:

{
    role: 'admin'
}

Возникнет ошибка.


Ограничение количества ключей

min()

Минимальное количество свойств:

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

Объект должен содержать минимум одно поле.


max()

const schema = Joi.object({
    a: Joi.any(),
    b: Joi.any(),
    c: Joi.any()
}).max(2);

length()

Точное количество ключей:

const schema = Joi.object({
    x: Joi.any(),
    y: Joi.any()
}).length(2);

Разрешённые и запрещённые ключи

unknown(false)

Запрет дополнительных полей:

const schema = Joi.object({
    username: Joi.string()
}).unknown(false);

Проверка:

schema.validate({
    username: 'alex',
    role: 'admin'
});

Ошибка:

"role" is not allowed

unknown(true)

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

const schema = Joi.object({
    id: Joi.number()
}).unknown(true);

Теперь дополнительные поля допустимы.


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

Глубокая схема

const schema = Joi.object({
    user: Joi.object({
        profile: Joi.object({
            firstName: Joi.string(),
            lastName: Joi.string()
        })
    })
});

Проверяемый объект:

{
    user: {
        profile: {
            firstName: 'Ivan',
            lastName: 'Petrov'
        }
    }
}

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

Выделение отдельных schema

const addressSchema = Joi.object({
    city: Joi.string().required(),
    street: Joi.string().required(),
    house: Joi.string().required()
});

const userSchema = Joi.object({
    name: Joi.string(),
    address: addressSchema
});

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

  • компактнее;
  • удобнее для поддержки;
  • проще для тестирования.

append()

Добавление новых ключей в существующую схему.

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

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

Теперь схема содержит:

{
    name,
    age
}

extract()

Получение схемы отдельного ключа.

const schema = Joi.object({
    username: Joi.string().min(5),
    password: Joi.string().min(8)
});

const usernameSchema = schema.extract('username');

Проверка:

usernameSchema.validate('admin');

fork()

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

Пример

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

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

Теперь username и password обязательны.


rename()

Переименование ключей.

const schema = Joi.object({
    username: Joi.string()
}).rename('login', 'username');

Проверка:

schema.validate({
    login: 'admin'
});

После обработки:

{
    username: 'admin'
}

pattern()

Проверка динамических ключей.

Проверка ключей по регулярному выражению

const schema = Joi.object().pattern(
    /^item_/,
    Joi.number()
);

Допустимый объект:

{
    item_1: 10,
    item_2: 20
}

Недопустимый:

{
    item_1: 'text'
}

schema-композиция

concat()

Объединение схем.

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

const schemaB = Joi.object({
    age: Joi.number()
});

const merged = schemaA.concat(schemaB);

Результат:

{
    name,
    age
}

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

alternatives()

Позволяет использовать разные варианты структуры.

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

Допустимы:

'hello'
100

Условные схемы

when()

const schema = Joi.object({
    type: Joi.string().required(),

    value: Joi.when('type', {
        is: 'number',
        then: Joi.number().required(),
        otherwise: Joi.string().required()
    })
});

Проверка:

{
    type: 'number',
    value: 10
}

Связь ключей

with()

Если существует один ключ — требуется другой.

const schema = Joi.object({
    password: Joi.string(),
    repeatPassword: Joi.string()
}).with('password', 'repeatPassword');

without()

Запрет совместного использования.

const schema = Joi.object({
    token: Joi.string(),
    password: Joi.string()
}).without('token', 'password');

xor()

Разрешён только один ключ.

const schema = Joi.object({
    email: Joi.string(),
    phone: Joi.string()
}).xor('email', 'phone');

Допустимо:

{
    email: 'test@mail.com'
}

Недопустимо:

{
    email: 'a@mail.com',
    phone: '+123'
}

and()

Все или ничего.

const schema = Joi.object({
    startDate: Joi.date(),
    endDate: Joi.date()
}).and('startDate', 'endDate');

or()

Хотя бы одно поле.

const schema = Joi.object({
    email: Joi.string(),
    phone: Joi.string(),
    telegram: Joi.string()
}).or('email', 'phone', 'telegram');

Ссылки между ключами

ref()

Использование значения другого поля.

const schema = Joi.object({
    password: Joi.string().required(),

    repeatPassword: Joi.any()
        .valid(Joi.ref('password'))
        .required()
});

Проверка:

{
    password: '123456',
    repeatPassword: '123456'
}

describe()

Вывод структуры схемы.

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

console.log(schema.describe());

Полезно для:

  • генерации документации;
  • автоматического анализа схем;
  • построения UI;
  • дебага.

Метаданные схем

meta()

Добавление произвольной информации.

const schema = Joi.string().meta({
    uiType: 'input'
});

note()

const schema = Joi.string().note('Поле используется для авторизации');

tag()

const schema = Joi.string().tag('auth');

Изменение поведения схем

prefs()

Настройка параметров валидации.

const schema = Joi.object({
    age: Joi.number()
}).prefs({
    abortEarly: false
});

abortEarly

true

Остановка на первой ошибке.

false

Сбор всех ошибок.

Пример:

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

const result = schema.validate(
    {
        username: 'ab',
        age: 10
    },
    {
        abortEarly: false
    }
);

console.log(result.error.details);

strip()

Удаление поля после проверки.

const schema = Joi.object({
    username: Joi.string(),
    password: Joi.string().strip()
});

Результат:

{
    username: 'admin'
}

default()

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

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

Проверка:

schema.validate({});

Результат:

{
    role: 'user'
}

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

convert

По умолчанию Joi преобразует значения.

const schema = Joi.number();

schema.validate('100');

Результат:

100

Отключение convert

const schema = Joi.number().prefs({
    convert: false
});

Теперь строка "100" вызовет ошибку.


Кастомные схемы

custom()

const schema = Joi.string().custom((value, helpers) => {

    if (value.includes('admin')) {
        return helpers.error('string.invalid');
    }

    return value;
});

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

validateAsync()

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

async function run() {

    try {

        const result = await schema.validateAsync({
            username: 'alex'
        });

        console.log(result);

    } catch (err) {

        console.log(err);
    }
}

Пример сложной схемы

const Joi = require('joi');

const addressSchema = Joi.object({
    city: Joi.string().required(),
    street: Joi.string().required(),
    zip: Joi.string().pattern(/^\d{6}$/)
});

const userSchema = Joi.object({

    id: Joi.number()
        .integer()
        .positive(),

    username: Joi.string()
        .alphanum()
        .min(3)
        .max(20)
        .required(),

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

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

    roles: Joi.array()
        .items(Joi.string())
        .default(['user']),

    address: addressSchema.required(),

    password: Joi.string()
        .min(8)
        .required(),

    repeatPassword: Joi.any()
        .valid(Joi.ref('password'))
        .required()

}).with('password', 'repeatPassword');

Практика построения schema

Разделение схем по уровням

Хорошая практика:

schemas/
    user.schema.js
    auth.schema.js
    product.schema.js

Базовые схемы

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

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

const productSchema = Joi.object({
    id: idSchema,
    title: Joi.string()
});

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

Использование unknown(true) без необходимости

Проблема:

Joi.object({...}).unknown(true)

Может пропускать:

  • лишние поля;
  • вредоносные данные;
  • ошибки клиента.

Отсутствие required()

Joi.object({
    email: Joi.string().email()
})

Поле необязательное, хотя часто предполагается обратное.


Слишком большие схемы

Плохо:

const schema = Joi.object({
    ...
    // сотни строк
});

Лучше:

  • декомпозиция;
  • разделение на вложенные схемы;
  • повторное использование.

Архитектурный подход к schema

Крупные проекты обычно используют:

  • отдельный слой validation;
  • схемы для DTO;
  • переиспользуемые части;
  • генерацию API-документации;
  • централизованные правила валидации.

Пример структуры:

src/
    validation/
        user/
        auth/
        product/

Joi schema как контракт данных

Схема Joi фактически становится контрактом между:

  • клиентом;
  • сервером;
  • API;
  • базой данных;
  • внутренними сервисами.

Благодаря keys() и schema-композиции можно:

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