Ссылки через $ref

Ключевое слово $ref используется для повторного применения схем JSON Schema без дублирования структуры. Вместо копирования одинаковых описаний объектов схема может ссылаться на другую схему или её часть.

Ajv полностью поддерживает механизм ссылок JSON Schema и активно использует его для:

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

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

const Ajv = require("ajv")

const ajv = new Ajv()

const schema = {
  definitions: {
    user: {
      type: "object",
      properties: {
        id: { type: "integer" },
        name: { type: "string" }
      },
      required: ["id", "name"]
    }
  },

  type: "object",

  properties: {
    author: {
      $ref: "#/definitions/user"
    }
  }
}

const validate = ajv.compile(schema)

console.log(validate({
  author: {
    id: 1,
    name: "Alex"
  }
}))

Результат:

true

Локальные ссылки

Ссылка на часть схемы

Наиболее распространённый вариант — ссылка на внутренний раздел схемы через JSON Pointer.

{
  $ref: "#/definitions/user"
}

Символ # означает текущий документ.

Путь:

#/definitions/user

указывает на:

definitions: {
  user: { ... }
}

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

Хранилище переиспользуемых схем

В старых версиях JSON Schema обычно использовалось поле definitions.

const schema = {
  definitions: {
    address: {
      type: "object",
      properties: {
        city: { type: "string" },
        zip: { type: "string" }
      },
      required: ["city", "zip"]
    }
  },

  type: "object",

  properties: {
    shippingAddress: {
      $ref: "#/definitions/address"
    },

    billingAddress: {
      $ref: "#/definitions/address"
    }
  }
}

Обе структуры используют одну и ту же схему.

Это уменьшает:

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

$defs вместо definitions

Современный стандарт

В новых версиях JSON Schema (2019-09, 2020-12) вместо definitions используется $defs.

const schema = {
  $defs: {
    product: {
      type: "object",
      properties: {
        id: { type: "integer" },
        title: { type: "string" }
      },
      required: ["id", "title"]
    }
  },

  type: "array",

  items: {
    $ref: "#/$defs/product"
  }
}

Ajv поддерживает оба варианта.


Внешние схемы

Разделение схем по файлам

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

user.schema.json

{
  "$id": "https://example.com/schemas/user.json",

  "type": "object",

  "properties": {
    "id": {
      "type": "integer"
    },

    "name": {
      "type": "string"
    }
  },

  "required": ["id", "name"]
}

post.schema.json

{
  "type": "object",

  "properties": {
    "title": {
      "type": "string"
    },

    "author": {
      "$ref": "https://example.com/schemas/user.json"
    }
  }
}

$id в Ajv

Идентификатор схемы

Ajv использует $id как уникальный адрес схемы.

const userSchema = {
  $id: "https://example.com/schemas/user.json",

  type: "object",

  properties: {
    id: { type: "integer" },
    name: { type: "string" }
  }
}

После регистрации схемы:

ajv.addSchema(userSchema)

она становится доступной через $ref.


Регистрация схем

addSchema

Ajv должен знать все схемы, на которые существуют ссылки.

const Ajv = require("ajv")

const ajv = new Ajv()

ajv.addSchema(userSchema)

const validate = ajv.compile(postSchema)

Если ссылка указывает на неизвестную схему, Ajv выбросит ошибку.


Массив схем

Регистрация нескольких схем сразу

ajv.addSchema([
  userSchema,
  addressSchema,
  productSchema
])

Именованные схемы

Регистрация через ключ

Схему можно зарегистрировать под собственным именем.

ajv.addSchema(userSchema, "user")

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

{
  $ref: "user"
}

Абсолютные и относительные ссылки

Абсолютный $ref

{
  $ref: "https://example.com/schemas/user.json"
}

Относительный $ref

{
  $ref: "user.json"
}

Относительные ссылки вычисляются относительно $id.


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

Пример

const schema = {
  $id: "https://example.com/schemas/post.json",

  properties: {
    author: {
      $ref: "user.json"
    }
  }
}

Ajv преобразует ссылку в:

https://example.com/schemas/user.json

Ссылки на вложенные части внешних схем

JSON Pointer

{
  $ref: "user.json#/properties/address"
}

Сначала загружается схема user.json, затем выбирается раздел:

properties.address

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

Деревья и вложенные структуры

$ref позволяет описывать рекурсивные модели.

Пример дерева категорий:

const categorySchema = {
  $id: "category",

  type: "object",

  properties: {
    name: {
      type: "string"
    },

    children: {
      type: "array",

      items: {
        $ref: "category"
      }
    }
  }
}

Проверка:

const validate = ajv.compile(categorySchema)

validate({
  name: "Root",

  children: [
    {
      name: "Child"
    }
  ]
})

Рекурсия через локальную ссылку

const schema = {
  $id: "node",

  type: "object",

  properties: {
    value: {
      type: "string"
    },

    next: {
      $ref: "node"
    }
  }
}

Циклические ссылки

Ajv умеет корректно обрабатывать циклические $ref.

Пример:

A -> B
B -> A

Однако подобные схемы могут усложнять:

  • отладку;
  • генерацию документации;
  • анализ ошибок.

Ошибки разрешения ссылок

MissingRefError

Типичная ошибка:

MissingRefError: can't resolve reference user.json

Причины:

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

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

getSchema

const schema = ajv.getSchema("user")

или:

const schema = ajv.getSchema(
  "https://example.com/schemas/user.json"
)

Компиляция ссылок

Как Ajv обрабатывает $ref

Во время compile() Ajv:

  1. находит все ссылки;
  2. разрешает адреса;
  3. объединяет схемы;
  4. генерирует оптимизированный JavaScript-код валидатора.

Поэтому производительность Ajv остаётся высокой даже при большом количестве $ref.


Inline и отдельные функции

Ajv может:

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

Поведение зависит от:

  • размера схемы;
  • количества повторений;
  • настроек Ajv.

Опция inlineRefs

Управление инлайнингом

const ajv = new Ajv({
  inlineRefs: true
})

Варианты:

inlineRefs: true
inlineRefs: false
inlineRefs: 10

true

Мелкие схемы встраиваются в код.

false

Каждая ссылка становится отдельной функцией.

Число

Лимит размера схемы для инлайнинга.


Асинхронная загрузка схем

loadSchema

Ajv умеет автоматически загружать внешние схемы.

const ajv = new Ajv({
  loadSchema: async (uri) => {
    const response = await fetch(uri)
    return response.json()
  }
})

Асинхронная компиляция

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

await ajv.compileAsync(schema)

Пример асинхронной загрузки

const Ajv = require("ajv")

const ajv = new Ajv({
  loadSchema: async (uri) => {
    const response = await fetch(uri)
    return response.json()
  }
})

const schema = {
  $ref: "https://example.com/user.json"
}

async function run() {
  const validate = await ajv.compileAsync(schema)

  console.log(validate({
    id: 1,
    name: "John"
  }))
}

run()

Якоря $anchor

Именованные точки внутри схемы

Современный JSON Schema поддерживает $anchor.

{
  $defs: {
    user: {
      $anchor: "user",

      type: "object",

      properties: {
        name: {
          type: "string"
        }
      }
    }
  }
}

Ссылка:

{
  $ref: "#user"
}

Dynamic References

$dynamicRef и $dynamicAnchor

Новые версии JSON Schema поддерживают динамические ссылки.

{
  $dynamicAnchor: "node"
}
{
  $dynamicRef: "#node"
}

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

Ajv поддерживает их в современных draft-версиях.


Различие между $ref и allOf

$ref

Полностью заменяет текущую схему.

{
  $ref: "#/$defs/user"
}

allOf

Комбинирует схемы.

{
  allOf: [
    { $ref: "#/$defs/user" },

    {
      properties: {
        active: {
          type: "boolean"
        }
      }
    }
  ]
}

Важная особенность $ref

Игнорирование соседних свойств

В draft-07 и старых версиях JSON Schema:

{
  $ref: "#/$defs/user",

  type: "object"
}

поле type будет проигнорировано.

Работает только $ref.

Это одна из наиболее распространённых ошибок.


Корректное расширение схемы

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

Правильный вариант:

{
  allOf: [
    {
      $ref: "#/$defs/user"
    },

    {
      type: "object",

      properties: {
        active: {
          type: "boolean"
        }
      }
    }
  ]
}

Структурирование больших схем

Практический подход

Для крупных проектов обычно создаются:

schemas/
├── user.json
├── product.json
├── order.json
├── address.json
└── payment.json

Каждая схема:

  • имеет $id;
  • отвечает за одну сущность;
  • переиспользуется через $ref.

Паттерн Entity Schema

Базовые сущности

// user.schema.json
{
  "$id": "user",

  "$defs": {
    "id": {
      "type": "integer"
    }
  },

  "type": "object",

  "properties": {
    "id": {
      "$ref": "#/$defs/id"
    }
  }
}

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

Ссылки не только на объекты

const schema = {
  $defs: {
    email: {
      type: "string",
      format: "email"
    }
  },

  type: "object",

  properties: {
    contactEmail: {
      $ref: "#/$defs/email"
    }
  }
}

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

const schema = {
  $defs: {
    tags: {
      type: "array",
      items: {
        type: "string"
      }
    }
  },

  properties: {
    labels: {
      $ref: "#/$defs/tags"
    }
  }
}

Ошибки JSON Pointer

Неверный путь

Ошибка:

{
  $ref: "#/defs/user"
}

если раздел называется $defs.

Правильно:

{
  $ref: "#/$defs/user"
}

Экранирование символов в Pointer

JSON Pointer использует специальные правила:

Символ Замена
~ ~0
/ ~1

Пример:

{
  $ref: "#/$defs/a~1b"
}

соответствует ключу:

"a/b"

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

Экспорт и импорт

userSchema.js

module.exports = {
  $id: "user",

  type: "object",

  properties: {
    id: { type: "integer" }
  }
}

app.js

const Ajv = require("ajv")
const userSchema = require("./userSchema")

const ajv = new Ajv()

ajv.addSchema(userSchema)

Оптимизация структуры схем

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

Плохо:

$defs -> common -> entities -> user

Лучше:

$defs -> user

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

  • навигацию;
  • поддержку;
  • пути $ref.

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

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

Нельзя:

{
  $id: "user"
}

в нескольких схемах одновременно.

Ajv использует $id как уникальный идентификатор.


Неверный URI

Ошибка:

$id: "user schema"

Желательно использовать корректные URI:

$id: "https://example.com/schemas/user.json"

или:

$id: "user"

Смешивание draft-версий

Некоторые возможности $ref зависят от версии JSON Schema.

Например:

  • definitions — старый стиль;
  • $defs — новый стиль;
  • $dynamicRef доступен только в новых draft.

Draft и Ajv

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

Ajv поддерживает:

  • draft-04;
  • draft-06;
  • draft-07;
  • 2019-09;
  • 2020-12.

Версия влияет на:

  • поведение $ref;
  • правила обработки ссылок;
  • доступные ключевые слова.

Проверка схем

validateSchema

const valid = ajv.validateSchema(schema)

console.log(valid)

Ajv способен проверить корректность самих $ref.


Генерация ошибок

Пример ошибки ссылки

console.log(validate.errors)

Результат:

[
  {
    instancePath: "/author",
    schemaPath: "#/properties/author/type",
    keyword: "type",
    message: "must be object"
  }
]

Даже если ошибка возникла внутри $ref, Ajv показывает полный путь.


Архитектура схем через $ref

Основная идея

Ссылки превращают JSON Schema в систему взаимосвязанных модулей.

Преимущества:

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

В больших приложениях $ref становится фундаментом всей структуры валидации данных.