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

При разработке больших приложений схемы валидации быстро начинают повторяться. Одни и те же правила используются в формах регистрации, API-запросах, конфигурационных файлах, административных панелях и внутренних сервисах. Библиотека Joi предоставляет несколько механизмов для повторного использования схем без копирования кода.

Основные подходы:

  • вынесение схем в отдельные константы;
  • композиция через .keys();
  • расширение схем через .concat();
  • использование ссылок (Joi.ref);
  • применение .shared() и .link();
  • создание фабрик схем;
  • параметризация схем;
  • использование defaults;
  • наследование правил через alter() и tailor().

Базовое переиспользование через константы

Самый простой и распространённый способ — вынести повторяющиеся схемы в отдельные переменные.

const Joi = require('joi');

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

const passwordSchema = Joi.string()
    .min(8)
    .max(64)
    .required();

Теперь схемы можно использовать повторно:

const registerSchema = Joi.object({
    email: emailSchema,
    password: passwordSchema
});

const loginSchema = Joi.object({
    email: emailSchema,
    password: passwordSchema
});

Такой подход:

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

Композиция объектов через .keys()

Метод .keys() позволяет расширять существующие объектные схемы.

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

const baseUserSchema = Joi.object({
    id: Joi.number().integer().positive(),
    email: Joi.string().email().required()
});

Расширение схемы

const fullUserSchema = baseUserSchema.keys({
    firstName: Joi.string().required(),
    lastName: Joi.string().required()
});

Полученная схема содержит:

{
    id,
    email,
    firstName,
    lastName
}

Повторное использование частей объекта

Часто часть структуры повторяется в нескольких схемах.

Схема адреса

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

Использование в других схемах

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

const companySchema = Joi.object({
    title: Joi.string().required(),
    office: addressSchema
});

Расширение схем через .concat()

Метод .concat() объединяет две совместимые схемы.

Пример объединения строковых правил

const baseNameSchema = Joi.string();

const requiredNameSchema = baseNameSchema.concat(
    Joi.string().min(3).required()
);

Результат:

Joi.string().min(3).required()

Объединение объектных схем

const timestampsSchema = Joi.object({
    createdAt: Joi.date(),
    updatedAt: Joi.date()
});

const postSchema = Joi.object({
    title: Joi.string().required(),
    content: Joi.string().required()
}).concat(timestampsSchema);

Использование фабрик схем

Во многих случаях правила зависят от параметров. Для этого удобно использовать функции.

Фабрика строковых схем

function createNameSchema(min, max) {
    return Joi.string().min(min).max(max).required();
}

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

const shortName = createNameSchema(2, 20);

const longName = createNameSchema(10, 100);

Параметризованные схемы

Фабрики особенно полезны при создании CRUD-схем.

Генерация схемы пагинации

function createPaginationSchema(maxLimit = 100) {
    return Joi.object({
        page: Joi.number().integer().min(1).default(1),

        limit: Joi.number()
            .integer()
            .min(1)
            .max(maxLimit)
            .default(10)
    });
}

Использование общих правил

Часто набор правил применяется к разным полям.

Общая схема идентификатора

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

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

const articleSchema = Joi.object({
    articleId: idSchema,
    authorId: idSchema
});

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

Схемы могут включать другие схемы любого уровня вложенности.

const geoSchema = Joi.object({
    lat: Joi.number().required(),
    lng: Joi.number().required()
});

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

const storeSchema = Joi.object({
    title: Joi.string().required(),
    address: addressSchema
});

Использование Joi.ref()

Ссылки позволяют использовать значения других полей.

Подтверждение пароля

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

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

Зависимость от вложенных полей

const schema = Joi.object({
    user: Joi.object({
        role: Joi.string().required()
    }),

    permissions: Joi.array().when('user.role', {
        is: 'admin',
        then: Joi.required(),
        otherwise: Joi.optional()
    })
});

Повторное использование через .shared()

Метод .shared() регистрирует схему для дальнейшего использования через ссылки.

Общая схема

const schema = Joi.object({
    user: Joi.link('#user')
}).shared(
    Joi.object({
        id: Joi.number().required(),
        name: Joi.string().required()
    }).id('user')
);

Использование .link()

.link() позволяет ссылаться на ранее определённую схему.

Пример

const schema = Joi.object({
    author: Joi.object({
        id: Joi.number().required(),
        name: Joi.string().required()
    }).id('person'),

    reviewer: Joi.link('#person')
});

Это особенно полезно:

  • при больших схемах;
  • при глубокой вложенности;
  • при рекурсивных структурах.

Рекурсивные схемы

Одно из важнейших применений .link() — создание рекурсивных структур.

Дерево категорий

const categorySchema = Joi.object({
    title: Joi.string().required(),

    children: Joi.array().items(
        Joi.link('#category')
    )
}).id('category');

Пример валидных данных:

{
    title: 'Electronics',
    children: [
        {
            title: 'Phones',
            children: []
        }
    ]
}

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

Общая схема массива идентификаторов

const idsSchema = Joi.array()
    .items(
        Joi.number().integer().positive()
    )
    .min(1);

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

const deleteSchema = Joi.object({
    ids: idsSchema.required()
});

const exportSchema = Joi.object({
    selectedIds: idsSchema
});

Создание базовых схем

В крупных проектах удобно создавать наборы базовых правил.

Базовые типы

const schemas = {
    id: Joi.number().integer().positive(),

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

    slug: Joi.string().pattern(/^[a-z0-9-]+$/),

    timestamp: Joi.date()
};

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

const postSchema = Joi.object({
    id: schemas.id.required(),
    slug: schemas.slug.required(),
    createdAt: schemas.timestamp
});

Расширение через defaults

defaults() позволяет автоматически применять правила ко всем схемам определённого типа.

Пример

const customJoi = Joi.defaults(schema => {
    switch (schema.type) {
        case 'string':
            return schema.trim();

        case 'object':
            return schema.options({
                stripUnknown: true
            });

        default:
            return schema;
    }
});

Теперь:

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

Строки автоматически обрезаются через .trim().


Создание модульной структуры

В реальных проектах схемы обычно разделяются по файлам.

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

schemas/
├── common/
│   ├── id.schema.js
│   ├── email.schema.js
│   └── pagination.schema.js
│
├── user/
│   ├── create-user.schema.js
│   ├── update-user.schema.js
│   └── user.schema.js
│
└── post/
    ├── create-post.schema.js
    └── update-post.schema.js

Использование схем обновления

При обновлении сущности часть полей становится необязательной.

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

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

Схема обновления

const updateUserSchema = createUserSchema.fork(
    ['name', 'email', 'age'],
    schema => schema.optional()
);

Метод .fork()

.fork() массово изменяет поля схемы.

Пример

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

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

Использование .alter() и .tailor()

Эти методы позволяют создавать адаптируемые схемы.

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

const schema = Joi.object({
    title: Joi.string().alter({
        create: schema => schema.required(),

        update: schema => schema.optional()
    }),

    content: Joi.string().alter({
        create: schema => schema.required(),

        update: schema => schema.optional()
    })
});

Применение режима

const createSchema = schema.tailor('create');

const updateSchema = schema.tailor('update');

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

Общая условная логика

const priceSchema = Joi.number().when('type', {
    is: 'premium',
    then: Joi.number().min(100),
    otherwise: Joi.number().min(10)
});

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

const productSchema = Joi.object({
    type: Joi.string().required(),
    price: priceSchema
});

Создание схем для ролей

Базовые поля пользователя

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

Администратор

const adminSchema = baseSchema.keys({
    permissions: Joi.array()
        .items(Joi.string())
        .required()
});

Обычный пользователь

const userSchema = baseSchema.keys({
    subscription: Joi.string()
});

Использование .append()

.append() добавляет поля к объектной схеме.

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

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

Комбинирование техник

На практике обычно используется комбинация нескольких подходов.

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

const timestampsSchema = Joi.object({
    createdAt: Joi.date(),
    updatedAt: Joi.date()
});

function createEntitySchema(fields) {
    return Joi.object(fields)
        .keys({
            id: idSchema
        })
        .concat(timestampsSchema);
}

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

const articleSchema = createEntitySchema({
    title: Joi.string().required(),
    content: Joi.string().required()
});

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

Общий кастомный валидатор

const strongPassword = Joi.string().custom((value, helpers) => {
    const hasUppercase = /[A-Z]/.test(value);
    const hasNumber = /\d/.test(value);

    if (!hasUppercase || !hasNumber) {
        return helpers.error('password.weak');
    }

    return value;
});

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

const schema = Joi.object({
    password: strongPassword.required()
});

Избежание мутаций схем

Схемы Joi иммутабельны.

const base = Joi.string();

const required = base.required();

console.log(base === required);

Результат:

false

Каждый вызов создаёт новую схему.

Это позволяет безопасно переиспользовать схемы без риска случайного изменения.


Практика организации схем

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

const emailSchema = Joi.string()
    .email()
    .lowercase()
    .trim();
const createUserSchema = Joi.object({
    email: emailSchema.required()
});
const updateUserSchema = Joi.object({
    email: emailSchema.optional()
});

Плохая практика

Дублирование

const createUserSchema = Joi.object({
    email: Joi.string()
        .email()
        .lowercase()
        .trim()
        .required()
});
const updateUserSchema = Joi.object({
    email: Joi.string()
        .email()
        .lowercase()
        .trim()
});

Изменение правил потребует обновления сразу в нескольких местах.


Ограничение глубокой связанности

Чрезмерное переиспользование тоже может создавать проблемы.

Нежелательный пример

const base = Joi.object({...});

const level1 = base.keys({...});

const level2 = level1.keys({...});

const level3 = level2.keys({...});

Слишком длинные цепочки наследования:

  • усложняют поддержку;
  • ухудшают читаемость;
  • затрудняют отладку.

Рекомендуемый баланс

Оптимальная схема переиспользования обычно включает:

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

Такой подход обеспечивает:

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