Композиция и наследование

Композиция в Joi — это построение сложных схем валидации из более простых и переиспользуемых частей. Вместо дублирования правил создаются базовые схемы, которые затем расширяются, комбинируются и наследуются.

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

  • concat() — объединение схем
  • append() — расширение объекта новыми полями
  • keys() — переопределение структуры объекта
  • fork() — массовое изменение правил
  • extract() — извлечение вложенных схем
  • link() — ссылки между схемами
  • shared() — повторное использование именованных схем
  • alter() и tailor() — адаптация схем под разные сценарии
  • when() — условная композиция

Объединение схем через concat()

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

Базовый пример

const Joi = require('joi');

const baseSchema = Joi.string().min(3);

const extendedSchema = baseSchema.concat(
  Joi.string().max(10)
);

console.log(extendedSchema.validate('hello'));

Результирующая схема:

Joi.string().min(3).max(10)

Конфликт правил

При несовместимых типах Joi выбрасывает ошибку.

const schema1 = Joi.string();
const schema2 = Joi.number();

const schema = schema1.concat(schema2);

Ошибка:

Cannot merge type string with another type: number

Объединение object-схем

const credentialsSchema = Joi.object({
  login: Joi.string().required()
});

const profileSchema = Joi.object({
  age: Joi.number().min(18)
});

const userSchema = credentialsSchema.concat(profileSchema);

Результат:

{
  login: Joi.string().required(),
  age: Joi.number().min(18)
}

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

Метод append() добавляет новые ключи в объектную схему.

Добавление новых полей

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

const extendedSchema = baseSchema.append({
  email: Joi.string().email()
});

Результирующая схема:

{
  name: Joi.string(),
  email: Joi.string().email()
}

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

const entitySchema = Joi.object({
  id: Joi.number().required(),
  createdAt: Joi.date()
});

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

const commentSchema = entitySchema.append({
  message: Joi.string().required()
});

Такой подход позволяет централизованно хранить общие правила.


Модификация структуры через keys()

Метод keys() переопределяет или дополняет объектную схему.

Расширение структуры

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

const extended = schema.keys({
  age: Joi.number()
});

Переопределение существующих полей

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

const updated = schema.keys({
  role: Joi.string().valid('admin', 'user')
});

Теперь поле role ограничено списком допустимых значений.


Наследование правил через fork()

Метод fork() позволяет массово изменять правила существующих полей.

Особенно полезен при разделении схем:

  • создание
  • обновление
  • частичное обновление
  • административные операции

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

const userSchema = Joi.object({
  name: Joi.string(),
  email: Joi.string().email(),
  password: Joi.string()
});

Превращение всех полей в required

const createSchema = userSchema.fork(
  ['name', 'email', 'password'],
  field => field.required()
);

Частичное обновление

const updateSchema = userSchema.fork(
  ['password'],
  field => field.optional()
);

Изменение нескольких уровней вложенности

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

const modified = schema.fork(
  ['profile.contacts.phone'],
  field => field.required()
);

Извлечение схем через extract()

Метод extract() позволяет получить вложенную схему по пути.

Получение схемы поля

const userSchema = Joi.object({
  profile: Joi.object({
    email: Joi.string().email()
  })
});

const emailSchema = userSchema.extract('profile.email');

Отдельная валидация части структуры

console.log(
  emailSchema.validate('admin@example.com')
);

Ссылки между схемами через link()

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


Использование идентификаторов

const personSchema = Joi.object({
  firstName: Joi.string(),
  lastName: Joi.string()
}).id('person');

const schema = Joi.object({
  author: Joi.link('#person'),
  editor: Joi.link('#person')
});

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

const addressSchema = Joi.object({
  city: Joi.string(),
  street: Joi.string()
}).id('address');

const companySchema = Joi.object({
  legalAddress: Joi.link('#address'),
  postalAddress: Joi.link('#address')
});

Shared-схемы

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


Регистрация общей схемы

const emailSchema = Joi.string()
  .email()
  .id('email');

const schema = Joi.object({
  primary: Joi.link('#email'),
  secondary: Joi.link('#email')
}).shared(emailSchema);

Условная композиция через when()

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


Базовое условие

const schema = Joi.object({
  role: Joi.string().valid('user', 'admin'),

  permissions: Joi.when('role', {
    is: 'admin',
    then: Joi.array().items(Joi.string()).required(),
    otherwise: Joi.forbidden()
  })
});

Несколько условий

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

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

Адаптация схем через alter() и tailor()

Механизм alter() позволяет заранее определить варианты изменения схемы.

tailor() применяет нужную модификацию.


Определение адаптаций

const schema = Joi.object({
  name: Joi.string().alter({
    create: field => field.required(),
    update: field => field.optional()
  }),

  email: Joi.string().email().alter({
    create: field => field.required(),
    update: field => field.optional()
  })
});

Применение варианта

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

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

Практическое применение

Сценарий создания

createSchema.validate({
  name: 'Alex'
});

Ошибка:

"email" is required

Сценарий обновления

updateSchema.validate({
  name: 'Alex'
});

Ошибки не будет.


Композиция вложенных объектов

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


Выделение отдельных модулей

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

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

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

Композиция массивов


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

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

const cartSchema = Joi.object({
  items: Joi.array().items(itemSchema)
});

Построение иерархии схем

Композиция особенно важна при моделировании доменной структуры приложения.


Базовая сущность

const entitySchema = Joi.object({
  id: Joi.number().required(),
  createdAt: Joi.date().required(),
  updatedAt: Joi.date()
});

Наследование сущностей

const userSchema = entitySchema.append({
  name: Joi.string().required(),
  email: Joi.string().email()
});

const articleSchema = entitySchema.append({
  title: Joi.string(),
  body: Joi.string()
});

Комбинирование условий и наследования


Сложная схема пользователя

const baseUserSchema = Joi.object({
  role: Joi.string()
});

const adminSchema = baseUserSchema.append({
  permissions: Joi.array()
});

const finalSchema = adminSchema.when(
  Joi.object({ role: Joi.valid('admin') }).unknown(),
  {
    then: Joi.object({
      permissions: Joi.required()
    })
  }
);

Динамическое наследование схем


Генерация схем

function createPaginationSchema(entitySchema) {
  return Joi.object({
    items: Joi.array().items(entitySchema),
    total: Joi.number(),
    page: Joi.number(),
    limit: Joi.number()
  });
}

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

const paginatedUsers =
  createPaginationSchema(userSchema);

const paginatedArticles =
  createPaginationSchema(articleSchema);

Глубокая композиция

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


Пример комплексной структуры

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

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

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

const userSchema = Joi.object({
  profile: Joi.object({
    firstName: Joi.string(),
    lastName: Joi.string()
  }),

  company: companySchema
});

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


Вынесение общих валидаторов

const requiredString = Joi.string().required();

const schema = Joi.object({
  firstName: requiredString,
  lastName: requiredString,
  city: requiredString
});

Композиция с alternatives()

alternatives() используется для объединения нескольких вариантов схем.


Несколько форматов данных

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

Композиция объектов

const adminSchema = Joi.object({
  role: Joi.valid('admin'),
  permissions: Joi.array().required()
});

const userSchema = Joi.object({
  role: Joi.valid('user')
});

const schema = Joi.alternatives().try(
  adminSchema,
  userSchema
);

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

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

schemas/
├── common/
├── user/
├── article/
├── comment/
└── shared/

Разделение по ответственности

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

base.schema.js

Схемы API

user.request.schema.js

Схемы БД

user.model.schema.js

Практический пример крупной композиции

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

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

const userSchema = Joi.object({
  id: idSchema,
  email: Joi.string().email(),
  name: Joi.string()
});

const commentSchema = timestampSchema.append({
  id: idSchema,
  message: Joi.string(),
  author: userSchema
});

const articleSchema = timestampSchema.append({
  id: idSchema,
  title: Joi.string(),
  body: Joi.string(),
  author: userSchema,
  comments: Joi.array().items(commentSchema)
});

Ошибки при композиции схем

Конфликт типов

Joi.string().concat(Joi.number());

Потеря required

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

const modified = schema.keys({
  name: Joi.string()
});

Флаг required() будет потерян.


Избыточная вложенность

Слишком глубокая композиция усложняет:

  • сопровождение
  • отладку
  • чтение схем
  • диагностику ошибок

Рекомендации по проектированию

Выделение атомарных схем

const emailSchema = Joi.string().email();
const passwordSchema = Joi.string().min(8);

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

function createEntitySchema(fields) {
  return Joi.object({
    id: Joi.number(),
    ...fields
  });
}

Разделение сценариев

createSchema
updateSchema
patchSchema
adminSchema
publicSchema

Минимизация дублирования

Повторяющиеся правила должны храниться централизованно.


Использование именованных ссылок

.id('user')
.link('#user')

Это особенно важно в больших проектах с десятками взаимосвязанных схем.