Рекурсивные схемы используются для описания структур данных, в которых элементы могут содержать объекты того же типа. Подобные структуры встречаются в деревьях, графах, вложенных комментариях, файловых системах, AST-деревьях, меню навигации и многих других моделях данных.
Ajv полностью поддерживает рекурсивные схемы JSON Schema, включая:
$ref$recursiveRef$defsРекурсивная схема — это схема, которая ссылается сама на себя.
Простейший пример — дерево категорий:
{
"name": "Electronics",
"children": [
{
"name": "Phones",
"children": []
}
]
}
Каждый узел дерева содержит:
const schema = {
type: "object",
properties: {
name: {
type: "string"
},
children: {
type: "array",
items: {
$ref: "#"
}
}
},
required: ["name", "children"],
additionalProperties: false
}
$ref: "#"Символ # указывает на корень текущей схемы.
Фактически:
items: {
$ref: "#"
}
означает:
items должны соответствовать всей текущей схеме
Получается рекурсивная структура:
node
└── children[]
└── node
└── children[]
└── node
import Ajv from "ajv"
const ajv = new Ajv()
const validate = ajv.compile(schema)
const data = {
name: "Root",
children: [
{
name: "Child 1",
children: []
},
{
name: "Child 2",
children: [
{
name: "Nested",
children: []
}
]
}
]
}
console.log(validate(data))
Результат:
true
const invalidData = {
name: "Root",
children: [
{
name: "Child",
children: [
{
children: []
}
]
}
]
}
Ошибка:
console.log(validate.errors)
Пример результата:
[
{
instancePath: "/children/0/children/0",
keyword: "required",
params: {
missingProperty: "name"
},
message: "must have required property 'name'"
}
]
Ajv корректно определяет путь даже внутри глубокой рекурсии.
$defsВ реальных схемах рекурсивные структуры обычно выносятся в
$defs.
$defsconst schema = {
$defs: {
node: {
type: "object",
properties: {
name: {
type: "string"
},
children: {
type: "array",
items: {
$ref: "#/$defs/node"
}
}
},
required: ["name", "children"],
additionalProperties: false
}
},
$ref: "#/$defs/node"
}
$defs
предпочтительнееТакой подход:
Классическая задача — древовидные комментарии.
{
"id": 1,
"text": "Главный комментарий",
"replies": [
{
"id": 2,
"text": "Ответ",
"replies": []
}
]
}
const schema = {
$defs: {
comment: {
type: "object",
properties: {
id: {
type: "integer"
},
text: {
type: "string"
},
replies: {
type: "array",
items: {
$ref: "#/$defs/comment"
}
}
},
required: ["id", "text", "replies"],
additionalProperties: false
}
},
$ref: "#/$defs/comment"
}
Иногда узлы дерева имеют разные типы.
Например:
{
"type": "folder",
"name": "src",
"children": [
{
"type": "file",
"name": "index.js"
}
]
}
const schema = {
$defs: {
file: {
type: "object",
properties: {
type: {
const: "file"
},
name: {
type: "string"
}
},
required: ["type", "name"],
additionalProperties: false
},
folder: {
type: "object",
properties: {
type: {
const: "folder"
},
name: {
type: "string"
},
children: {
type: "array",
items: {
$ref: "#/$defs/node"
}
}
},
required: ["type", "name", "children"],
additionalProperties: false
},
node: {
oneOf: [
{
$ref: "#/$defs/file"
},
{
$ref: "#/$defs/folder"
}
]
}
},
$ref: "#/$defs/node"
}
Рекурсия может быть не только прямой.
Иногда одна схема ссылается на другую, а та — обратно.
user
└── posts[]
└── post
└── author
└── user
const schema = {
$defs: {
user: {
type: "object",
properties: {
id: {
type: "integer"
},
posts: {
type: "array",
items: {
$ref: "#/$defs/post"
}
}
},
required: ["id", "posts"]
},
post: {
type: "object",
properties: {
title: {
type: "string"
},
author: {
$ref: "#/$defs/user"
}
},
required: ["title", "author"]
}
},
$ref: "#/$defs/user"
}
JSON Schema описывает структуру данных, а не реальные ссылки объектов JavaScript.
Поэтому схема:
{
$ref: "#"
}
не вызывает бесконечного цикла сама по себе.
Ajv строит внутренний граф схем и корректно обрабатывает циклические ссылки.
JSON Schema не содержит встроенного механизма ограничения глубины.
Однако существуют обходные решения.
const schema = {
$defs: {
level3: {
type: "object",
properties: {
value: {
type: "string"
}
}
},
level2: {
type: "object",
properties: {
value: {
type: "string"
},
child: {
$ref: "#/$defs/level3"
}
}
},
level1: {
type: "object",
properties: {
value: {
type: "string"
},
child: {
$ref: "#/$defs/level2"
}
}
}
},
$ref: "#/$defs/level1"
}
anyOfРекурсивные схемы часто комбинируются с anyOf,
oneOf и allOf.
const schema = {
$defs: {
literal: {
type: "object",
properties: {
type: {
const: "Literal"
},
value: {
type: "number"
}
},
required: ["type", "value"]
},
binaryExpression: {
type: "object",
properties: {
type: {
const: "BinaryExpression"
},
left: {
$ref: "#/$defs/expression"
},
right: {
$ref: "#/$defs/expression"
}
},
required: ["type", "left", "right"]
},
expression: {
anyOf: [
{
$ref: "#/$defs/literal"
},
{
$ref: "#/$defs/binaryExpression"
}
]
}
},
$ref: "#/$defs/expression"
}
Ajv поддерживает внешние ссылки.
node.json{
"$id": "https://example.com/node.json",
"type": "object",
"properties": {
"name": {
"type": "string"
},
"children": {
"type": "array",
"items": {
"$ref": "https://example.com/node.json"
}
}
}
}
import Ajv from "ajv"
const ajv = new Ajv()
ajv.addSchema(nodeSchema)
const validate = ajv.getSchema(
"https://example.com/node.json"
)
$recursiveRef и
$recursiveAnchorНачиная с Draft 2019-09 появился механизм динамической рекурсии.
$refОбычный $ref всегда указывает на фиксированную
схему.
Это затрудняет расширение рекурсивных схем через наследование.
const treeSchema = {
$id: "tree",
$recursiveAnchor: true,
type: "object",
properties: {
data: true,
children: {
type: "array",
items: {
$recursiveRef: "#"
}
}
}
}
$recursiveRef$recursiveRef ищет ближайший
$recursiveAnchor.
Это позволяет:
const baseSchema = {
$id: "base",
$recursiveAnchor: true,
type: "object",
properties: {
children: {
type: "array",
items: {
$recursiveRef: "#"
}
}
}
}
const extendedSchema = {
$id: "extended",
$recursiveAnchor: true,
allOf: [
{
$ref: "base"
},
{
properties: {
title: {
type: "string"
}
},
required: ["title"]
}
]
}
Теперь рекурсия будет ссылаться уже на extendedSchema, а
не на baseSchema.
Рекурсивные схемы могут создавать:
Особенно это заметно при:
anyOfoneOfdiscriminatorconst ajv = new Ajv({
discriminator: true
})
oneOfПлохо:
oneOf: [
...
...
...
...
]
Лучше:
properties: {
type: {
enum: ["a", "b"]
}
}
const ajv = new Ajv({
allErrors: false
})
$refОшибка:
$ref: "#/definitions/node"
при использовании $defs.
Правильно:
$ref: "#/$defs/node"
Ошибка:
required: ["name"]
без children.
Из-за этого часть узлов может оказаться неполной.
additionalPropertiesЕсли забыть:
additionalProperties: false
внутри рекурсивной схемы, структура может принимать произвольные поля на любом уровне вложенности.
console.log(validate.errors)
import Ajv from "ajv"
import addFormats from "ajv-formats"
const ajv = new Ajv({
allErrors: true,
verbose: true
})
Плагин:
npm install ajv-errors
import Ajv from "ajv"
import ajvErrors from "ajv-errors"
const ajv = new Ajv({
allErrors: true
})
ajvErrors(ajv)
element
└── children[]
└── element
menu
└── items[]
└── submenu
category
└── subcategories[]
└── category
expression
└── expression
└── expression
employee
└── subordinates[]
└── employee
$defsПредпочтительно:
$defs
вместо старого:
definitions
Хорошая практика:
$defs: {
node: { ... }
}
вместо сложной логики в корне схемы.
Для сложных деревьев полезно поле:
type
или:
kind
Почти всегда полезно:
additionalProperties: false
Для графовых структур:
id
или:
uuid
позволяют связывать узлы без прямой рекурсии объектов.
Ajv часто используется совместно с TypeScript.
type TreeNode = {
name: string
children: TreeNode[]
}
const schema = {
$defs: {
node: {
type: "object",
properties: {
name: {
type: "string"
},
children: {
type: "array",
items: {
$ref: "#/$defs/node"
}
}
},
required: ["name", "children"]
}
},
$ref: "#/$defs/node"
}
Ajv компилирует рекурсивные схемы в JavaScript-код.
Во время компиляции:
import standaloneCode from "ajv/dist/standalone"
Рекурсивные схемы также поддерживаются в standalone-режиме.
JSON Schema плохо подходит для:
JSON Schema валидирует структуру JSON, а не реальные объектные связи памяти.
Рекурсивные схемы особенно эффективны для: