Версионирование схем

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

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


Проблема изменения контрактов

Типичный сценарий:

Версия 1 API

const userSchemaV1 = Joi.object({
  username: Joi.string().required(),
  email: Joi.string().email().required()
});

Версия 2 API

Появляется новое поле:

const userSchemaV2 = Joi.object({
  username: Joi.string().required(),
  email: Joi.string().email().required(),
  age: Joi.number().integer().min(18)
});

Версия 3 API

Поле 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()
});

Расширение для V1

const userSchemaV1 = baseUserSchema.keys({
  username: Joi.string().required()
});

Расширение для V2

const userSchemaV2 = baseUserSchema.keys({
  username: Joi.string().required(),
  age: Joi.number().integer().min(18)
});

Расширение для V3

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()
});

Версия 1

Все поля необязательны.

Версия 2

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');

Что получится

Для v2

username обязателен

Для v3

username должен содержать минимум 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()
});

Версия 1

const v1 = userSchema.tailor('v1');

Правила

  • username обязателен;
  • login запрещён.

Версия 2

const v2 = userSchema.tailor('v2');

Правила

  • username обязателен;
  • минимум 5 символов;
  • login запрещён.

Версия 3

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()
  })
});

Поведение

V1

Поле разрешено.

V2

Поле вызовет ошибку:

{
  oldField: 'value'
}

Поддержка обратной совместимости

Иногда старая версия должна продолжать работать.


Мягкая миграция

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

  login: Joi.string()
}).oxor('username', 'login');

Что делает oxor()

Разрешает наличие только одного из полей.

Допустимо:

{
  username: 'john'
}

или

{
  login: 'john'
}

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

{
  username: 'john',
  login: 'john'
}

Поддержка deprecated-полей

Использование 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()
  })
});

Валидация разных версий API

Диспетчер схем

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

Версионирование массивов

Изменение структуры элементов

V1

const v1 = Joi.array().items(
  Joi.string()
);

V2

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

Soft deprecation — постепенное отключение функциональности.


Этап 1

Поле полностью поддерживается.

username: Joi.string()

Этап 2

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

username: Joi.string()
  .warning('deprecated')

Этап 3

Поле становится необязательным.

username: Joi.string()

Этап 4

Поле запрещается.

username: Joi.forbidden()

Стратегия compatibility layer

Иногда между клиентами и 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();
  });
});

Snapshot-тестирование схем

Иногда полезно фиксировать описание схемы.

expect(schema.describe()).toMatchSnapshot();

Метод describe()

describe() возвращает внутреннее представление схемы.


Пример

console.log(schema.describe());

Возможности

  • аудит изменений;
  • сравнение версий;
  • генерация документации;
  • snapshot-тесты;
  • автоматизация миграций.

Типичные ошибки при версионировании

Дублирование схем

Плохо:

const v1 = Joi.object({...});
const v2 = Joi.object({...});
const v3 = Joi.object({...});

Большие объёмы копипаста приводят к расхождению правил.


Жёсткое удаление полей

Резкое удаление старых параметров ломает клиентов.


Отсутствие migration window

Нужен переходный период, когда поддерживаются обе версии.


Смешивание бизнес-логики и валидации

Плохо:

if (apiVersion === 2 && user.role === 'admin')

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


Рекомендации по архитектуре

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

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


Использование alter() для эволюции

alter() значительно снижает дублирование.


Использование tailor() вместо ручного ветвления

Плохо:

if (version === 1) {
  ...
}

Лучше:

schema.tailor(version);

Использование warning-переходов

Deprecated-поля лучше удалять постепенно.


Изоляция legacy-кода

Старые версии API желательно отделять от новых модулей.


Документирование изменений

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

  • описанием;
  • причиной;
  • сроками удаления;
  • стратегией миграции;
  • примерами payload-ов.