Динамическая генерация схем

Валидация данных редко ограничивается статическими JSON-схемами. В реальных приложениях структура данных часто зависит от:

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

Библиотека Ajv позволяет не только валидировать данные по заранее подготовленным схемам, но и динамически создавать схемы во время выполнения программы.

Динамическая генерация схем особенно полезна в:

  • REST API;
  • конструкторах форм;
  • low-code системах;
  • ORM;
  • CMS;
  • системах конфигурации;
  • генераторах SDK;
  • GraphQL-шлюзах;
  • микросервисных архитектурах.

Генерация схем из конфигурации

Базовая идея

Схема создаётся обычным JavaScript-кодом. Поскольку JSON Schema — это объект JavaScript, его можно формировать динамически.

Пример:

function createUserSchema(requiredFields = []) {
  return {
    type: "object",
    properties: {
      name: {
        type: "string"
      },
      age: {
        type: "integer"
      }
    },
    required: requiredFields,
    additionalProperties: false
  }
}

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

const schema = createUserSchema(["name"])

const validate = ajv.compile(schema)

console.log(validate({
  name: "Alex",
  age: 25
}))

Генерация схем на основе ролей

Разные правила для разных пользователей

Частая задача — различная валидация для администратора и обычного пользователя.

function createProfileSchema(role) {
  const properties = {
    username: {
      type: "string"
    }
  }

  const required = ["username"]

  if (role === "admin") {
    properties.permissions = {
      type: "array",
      items: {
        type: "string"
      }
    }

    required.push("permissions")
  }

  return {
    type: "object",
    properties,
    required,
    additionalProperties: false
  }
}

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

const adminSchema = createProfileSchema("admin")

const validate = ajv.compile(adminSchema)

Динамическое добавление свойств

Формирование properties во время выполнения

function createSchema(fields) {
  const properties = {}

  for (const field of fields) {
    properties[field.name] = {
      type: field.type
    }
  }

  return {
    type: "object",
    properties,
    required: fields
      .filter(f => f.required)
      .map(f => f.name)
  }
}

Конфигурация:

const fields = [
  {
    name: "title",
    type: "string",
    required: true
  },
  {
    name: "views",
    type: "integer",
    required: false
  }
]

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

const schema = createSchema(fields)

Полученная схема:

{
  type: "object",
  properties: {
    title: {
      type: "string"
    },
    views: {
      type: "integer"
    }
  },
  required: ["title"]
}

Генерация вложенных схем

Рекурсивное построение

В сложных системах структура данных бывает древовидной.

Пример конфигурации:

const structure = {
  type: "object",
  fields: {
    name: "string",
    profile: {
      type: "object",
      fields: {
        age: "integer"
      }
    }
  }
}

Функция генерации:

function buildSchema(node) {
  const schema = {
    type: node.type,
    properties: {}
  }

  for (const [key, value] of Object.entries(node.fields)) {
    if (typeof value === "string") {
      schema.properties[key] = {
        type: value
      }
    } else {
      schema.properties[key] = buildSchema(value)
    }
  }

  return schema
}

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

const schema = buildSchema(structure)

Генерация схем для форм

Динамические формы

Многие UI-конструкторы создают поля формы из конфигурации.

Конфигурация:

const formConfig = [
  {
    field: "email",
    type: "string",
    format: "email",
    required: true
  },
  {
    field: "password",
    type: "string",
    minLength: 8,
    required: true
  }
]

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

function generateFormSchema(config) {
  const properties = {}
  const required = []

  for (const item of config) {
    properties[item.field] = {
      type: item.type
    }

    if (item.format) {
      properties[item.field].format = item.format
    }

    if (item.minLength) {
      properties[item.field].minLength = item.minLength
    }

    if (item.required) {
      required.push(item.field)
    }
  }

  return {
    type: "object",
    properties,
    required
  }
}

Генерация enum из базы данных

Динамический список допустимых значений

async function createCategorySchema() {
  const categories = await db.categories.findMany()

  return {
    type: "string",
    enum: categories.map(c => c.slug)
  }
}

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

const schema = await createCategorySchema()

const validate = ajv.compile(schema)

Генерация схем из TypeScript-моделей

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

Популярный подход — генерация JSON Schema из TypeScript-интерфейсов.

Инструменты:

  • typescript-json-schema
  • ts-json-schema-generator
  • typebox
  • zod-to-json-schema

Пример интерфейса:

interface User {
  id: number
  name: string
  active: boolean
}

Полученная схема:

{
  type: "object",
  properties: {
    id: {
      type: "number"
    },
    name: {
      type: "string"
    },
    active: {
      type: "boolean"
    }
  },
  required: ["id", "name", "active"]
}

Фабрики схем

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

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

function createEntitySchema(entityName, extraProperties = {}) {
  return {
    type: "object",
    properties: {
      id: {
        type: "integer"
      },
      createdAt: {
        type: "string",
        format: "date-time"
      },
      ...extraProperties
    },
    required: ["id", "createdAt"],
    additionalProperties: false
  }
}

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

const productSchema = createEntitySchema("product", {
  title: {
    type: "string"
  },
  price: {
    type: "number"
  }
})

Генерация схем для API

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

Разные версии API могут иметь разные структуры данных.

function createApiSchema(version) {
  const schema = {
    type: "object",
    properties: {
      name: {
        type: "string"
      }
    },
    required: ["name"]
  }

  if (version >= 2) {
    schema.properties.email = {
      type: "string",
      format: "email"
    }

    schema.required.push("email")
  }

  return schema
}

Условная генерация правил

Добавление ограничений по параметрам

function createPasswordSchema(strictMode) {
  const schema = {
    type: "string",
    minLength: 6
  }

  if (strictMode) {
    schema.pattern =
      "^(?=.*[A-Z])(?=.*[0-9]).+$"

    schema.minLength = 12
  }

  return schema
}

Композиция динамических схем

Объединение частей схем

JSON Schema поддерживает:

  • allOf
  • anyOf
  • oneOf
  • not

Пример композиции:

function createUserSchema(options) {
  const schemas = []

  schemas.push({
    type: "object",
    properties: {
      name: {
        type: "string"
      }
    }
  })

  if (options.withEmail) {
    schemas.push({
      type: "object",
      properties: {
        email: {
          type: "string",
          format: "email"
        }
      },
      required: ["email"]
    })
  }

  return {
    allOf: schemas
  }
}

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

Динамический тип элементов

function createArraySchema(itemType) {
  return {
    type: "array",
    items: {
      type: itemType
    }
  }
}

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

const numberArraySchema =
  createArraySchema("number")

Генерация схем по JSON-данным

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

Иногда схема создаётся автоматически по образцу данных.

function inferSchema(data) {
  if (typeof data === "string") {
    return { type: "string" }
  }

  if (typeof data === "number") {
    return { type: "number" }
  }

  if (Array.isArray(data)) {
    return {
      type: "array",
      items: inferSchema(data[0])
    }
  }

  if (typeof data === "object") {
    const properties = {}

    for (const key in data) {
      properties[key] =
        inferSchema(data[key])
    }

    return {
      type: "object",
      properties
    }
  }
}

Кэширование динамических схем

Проблема повторной компиляции

Компиляция схемы в Ajv — дорогостоящая операция.

Неправильно:

app.post("/users", (req, res) => {
  const schema = createSchema(req.user.role)

  const validate = ajv.compile(schema)

  validate(req.body)
})

Каждый запрос создаёт новый валидатор.


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

const cache = new Map()

function getValidator(role) {
  if (!cache.has(role)) {
    const schema = createSchema(role)

    cache.set(
      role,
      ajv.compile(schema)
    )
  }

  return cache.get(role)
}

Динамическая регистрация схем

addSchema

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

ajv.addSchema({
  $id: "user",
  type: "object",
  properties: {
    name: {
      type: "string"
    }
  }
})

Позже:

const validate = ajv.getSchema("user")

Генерация идентификаторов схем

Уникальные $id

function createSchema(name) {
  return {
    $id: `schema://${name}`,
    type: "object"
  }
}

Динамические ссылки через $ref

Генерация зависимостей

function createSchemas(modules) {
  const schemas = {}

  for (const module of modules) {
    schemas[module.name] = {
      $id: module.name,
      type: "object",
      properties: {
        config: {
          $ref: module.configSchema
        }
      }
    }
  }

  return schemas
}

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

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

function createEventSchema(events) {
  return {
    oneOf: events.map(event => ({
      type: "object",
      properties: {
        type: {
          const: event.type
        },
        payload: event.payloadSchema
      },
      required: ["type", "payload"]
    }))
  }
}

Генерация схем из метаданных ORM

Пример с моделями

const model = {
  table: "users",
  columns: [
    {
      name: "id",
      type: "integer",
      nullable: false
    },
    {
      name: "email",
      type: "string",
      nullable: false
    }
  ]
}

Генерация:

function schemaFromModel(model) {
  const properties = {}
  const required = []

  for (const column of model.columns) {
    properties[column.name] = {
      type: column.type
    }

    if (!column.nullable) {
      required.push(column.name)
    }
  }

  return {
    type: "object",
    properties,
    required
  }
}

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

Динамическое API

function schemaBuilder() {
  const schema = {
    type: "object",
    properties: {}
  }

  return new Proxy(schema, {
    get(target, prop) {
      if (prop === "string") {
        return name => {
          target.properties[name] = {
            type: "string"
          }

          return proxy
        }
      }

      return target[prop]
    }
  })
}

Подобный подход используется в DSL и конструкторах схем.


Генерация схем для multi-tenant систем

Разные клиенты — разные структуры

function tenantSchema(tenant) {
  const base = {
    type: "object",
    properties: {
      id: {
        type: "integer"
      }
    }
  }

  if (tenant.features.crm) {
    base.properties.leadSource = {
      type: "string"
    }
  }

  if (tenant.features.analytics) {
    base.properties.metrics = {
      type: "array"
    }
  }

  return base
}

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

Конвертация спецификаций

OpenAPI уже содержит описания структур данных.

Пример:

components:
  schemas:
    User:
      type: object
      properties:
        name:
          type: string

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


Runtime-модификация схем

Изменение после создания

const schema = {
  type: "object",
  properties: {}
}

schema.properties.name = {
  type: "string"
}

schema.required = ["name"]

Иммутабельный подход

Без мутации объектов

Более безопасный стиль:

function extendSchema(schema, extra) {
  return {
    ...schema,
    properties: {
      ...schema.properties,
      ...extra
    }
  }
}

Генерация custom keywords

Динамическое создание правил

function registerMinWordsKeyword(limit) {
  ajv.addKeyword({
    keyword: `minWords${limit}`,
    type: "string",
    validate(schema, data) {
      return data.split(" ").length >= limit
    }
  })
}

Генерация схем из UI-конфигурации

Связь интерфейса и валидации

const ui = {
  controls: [
    {
      name: "age",
      component: "number-input"
    }
  ]
}

Преобразование:

function uiToSchema(ui) {
  const properties = {}

  for (const control of ui.controls) {
    if (control.component === "number-input") {
      properties[control.name] = {
        type: "number"
      }
    }
  }

  return {
    type: "object",
    properties
  }
}

Оптимизация динамической генерации

Основные проблемы производительности

Частые ошибки:

  • постоянная компиляция схем;
  • создание одинаковых объектов;
  • отсутствие кэша;
  • глубокое клонирование;
  • генерация огромных allOf-структур;
  • избыточные $ref.

Предварительная генерация

Иногда выгоднее заранее подготовить все варианты.

const validators = {
  admin: ajv.compile(
    createSchema("admin")
  ),
  user: ajv.compile(
    createSchema("user")
  )
}

Lazy-генерация

Отложенное создание

class ValidatorRegistry {
  constructor(ajv) {
    this.ajv = ajv
    this.cache = new Map()
  }

  get(type) {
    if (!this.cache.has(type)) {
      const schema = buildSchema(type)

      this.cache.set(
        type,
        this.ajv.compile(schema)
      )
    }

    return this.cache.get(type)
  }
}

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

validateSchema

Ajv умеет валидировать сами схемы.

const valid = ajv.validateSchema(schema)

if (!valid) {
  console.log(ajv.errors)
}

Это особенно важно при генерации сложных схем из внешних источников.


Генерация схем для плагинов

Расширяемая архитектура

function buildPluginSchema(plugins) {
  const properties = {}

  for (const plugin of plugins) {
    properties[plugin.name] =
      plugin.schema
  }

  return {
    type: "object",
    properties
  }
}

DSL для генерации схем

Создание собственного API

const schema = object({
  name: string().required(),
  age: number().min(18)
})

Подобные DSL обычно внутри генерируют JSON Schema для Ajv.


Автоматическое обновление схем

Реакция на изменения конфигурации

config.on("update", newConfig => {
  const schema =
    createSchema(newConfig)

  validator =
    ajv.compile(schema)
})

Генерация схем на основе feature flags

Управление функциональностью

function featureSchema(flags) {
  const properties = {
    username: {
      type: "string"
    }
  }

  if (flags.betaProfile) {
    properties.profileTheme = {
      type: "string"
    }
  }

  return {
    type: "object",
    properties
  }
}

Безопасность динамической генерации

Основные риски

При создании схем из внешних данных возможны:

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

Ограничение глубины

function buildSchema(node, depth = 0) {
  if (depth > 10) {
    throw new Error("Max depth exceeded")
  }

  return {
    type: "object",
    properties: {}
  }
}

Практическая архитектура

Типичный pipeline

Часто динамическая генерация выглядит так:

Метаданные
    ↓
Генератор схем
    ↓
JSON Schema
    ↓
Ajv.compile()
    ↓
Кэш валидаторов
    ↓
Валидация данных

Рекомендации по организации кода

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

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

schemas/
  generators/
  validators/
  cache/
  registry/

Изоляция генераторов

export function createUserSchema() {}
export function createOrderSchema() {}
export function createProductSchema() {}

Отдельный слой кэширования

export class SchemaCache {}

Централизованный registry

registry.register("user", schema)
registry.get("user")

Подход Schema-as-Code

Схемы как часть бизнес-логики

Динамические схемы позволяют:

  • описывать доменную модель;
  • централизовать правила;
  • генерировать документацию;
  • создавать UI;
  • строить API;
  • валидировать конфигурации;
  • синхронизировать frontend и backend.

Ajv превращается не просто в валидатор, а в основу системы типизации и контрактов приложения.