Skip to content

Commit 8b5e561

Browse files
feat: support draft2020 correctly (#9)
close ajv-validator/json-schema-migrate#7 --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
1 parent b3602a7 commit 8b5e561

13 files changed

+335
-222
lines changed

.changeset/seven-gifts-fold.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@unts/json-schema-migrate": minor
3+
---
4+
5+
feat: support `draft2020` correctly - close [#7](https://github.com/ajv-validator/json-schema-migrate/issues/7)

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@
8888
"prettier": "^3.5.3"
8989
},
9090
"typeCoverage": {
91-
"atLeast": 91.75,
91+
"atLeast": 92.1,
9292
"cache": true,
9393
"detail": true,
9494
"ignoreAsAssertion": true,

src/ajv.ts

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
2+
3+
import { Ajv2019 } from 'ajv/dist/2019.js'
4+
import { Ajv2020 } from 'ajv/dist/2020.js'
5+
import type AjvCore from 'ajv/dist/core.js'
6+
import type {
7+
AnySchema,
8+
AnySchemaObject,
9+
DataValidationCxt,
10+
} from 'ajv/dist/types/index.js'
11+
12+
import { constantResultSchema, metaSchema } from './common.js'
13+
import type { SchemaVersion } from './types.js'
14+
15+
const ajvCache: Partial<Record<'default' | 'draft2020', AjvCore.default>> = {}
16+
17+
/**
18+
* Retrieves an Ajv validator instance for a specified JSON Schema draft version.
19+
*
20+
* This function returns a cached Ajv instance if available, or creates a new one configured for either
21+
* 'draft2019' (default) or 'draft2020'. The created instance is enhanced with a custom "migrateSchema" keyword
22+
* that transforms JSON Schemas to be compatible with different specification drafts by adjusting properties
23+
* such as "id", "$schema", "constant", "enum", and various numeric constraints.
24+
*
25+
* @param version - The target JSON Schema version (defaults to 'draft2019').
26+
* @returns An Ajv instance configured for JSON Schema validation and migration.
27+
*
28+
* @remark The custom "migrateSchema" keyword may throw a TypeError if a schema's "id" is not a string, or an Error
29+
* if the "id" format is invalid for the given version during the migration process.
30+
*/
31+
export function getAjv(version: SchemaVersion = 'draft2019') {
32+
const isDraft2020 = version === 'draft2020'
33+
34+
const cacheKey = isDraft2020 ? 'draft2020' : 'default'
35+
36+
let ajv = ajvCache[cacheKey]
37+
38+
if (ajv) {
39+
return ajv
40+
}
41+
42+
ajv = new (isDraft2020 ? Ajv2020 : Ajv2019)({ allErrors: true })
43+
44+
ajv.addKeyword({
45+
keyword: 'migrateSchema',
46+
schemaType: 'string',
47+
modifying: true,
48+
metaSchema: { enum: ['draft7', 'draft2019', 'draft2020'] },
49+
// eslint-disable-next-line sonarjs/cognitive-complexity
50+
validate(
51+
version: SchemaVersion,
52+
schema: AnySchema,
53+
_parentSchema?: AnySchemaObject,
54+
dataCxt?: DataValidationCxt,
55+
) {
56+
if (typeof schema != 'object') {
57+
return true
58+
}
59+
60+
if (dataCxt) {
61+
const { parentData, parentDataProperty } = dataCxt
62+
const valid = constantResultSchema(schema)
63+
if (typeof valid == 'boolean') {
64+
parentData[parentDataProperty] = valid
65+
return true
66+
}
67+
}
68+
69+
const dsCopy = { ...schema }
70+
71+
for (const key in dsCopy) {
72+
delete schema[key]
73+
switch (key) {
74+
case 'id': {
75+
const { id } = dsCopy
76+
if (typeof id !== 'string') {
77+
throw new TypeError(
78+
`json-schema-migrate: schema id must be string`,
79+
)
80+
}
81+
if ((version === 'draft2019' || isDraft2020) && id.includes('#')) {
82+
const [$id, $anchor, ...rest] = id.split('#')
83+
if (rest.length > 0) {
84+
throw new Error(`json-schema-migrate: invalid schema id ${id}`)
85+
}
86+
if ($id) {
87+
schema.$id = $id
88+
}
89+
if ($anchor && $anchor !== '/') {
90+
schema.$anchor = $anchor
91+
}
92+
} else {
93+
schema.$id = id
94+
}
95+
break
96+
}
97+
case '$schema': {
98+
schema.$schema = metaSchema(version)
99+
break
100+
}
101+
case 'constant': {
102+
schema.const = dsCopy.constant
103+
break
104+
}
105+
case 'enum': {
106+
if (
107+
Array.isArray(dsCopy.enum) &&
108+
dsCopy.enum.length === 1 &&
109+
dsCopy.constant === undefined &&
110+
dsCopy.const === undefined
111+
) {
112+
schema.const = dsCopy.enum[0]
113+
} else {
114+
schema.enum = dsCopy.enum
115+
}
116+
break
117+
}
118+
case 'exclusiveMaximum': {
119+
migrateExclusive(schema, key, 'maximum')
120+
break
121+
}
122+
case 'exclusiveMinimum': {
123+
migrateExclusive(schema, key, 'minimum')
124+
break
125+
}
126+
case 'maximum': {
127+
if (dsCopy.exclusiveMaximum !== true) {
128+
schema.maximum = dsCopy.maximum
129+
}
130+
break
131+
}
132+
case 'minimum': {
133+
if (dsCopy.exclusiveMinimum !== true) {
134+
schema.minimum = dsCopy.minimum
135+
}
136+
break
137+
}
138+
case 'dependencies': {
139+
const deps = dsCopy.dependencies as Record<string, unknown>
140+
if (version === 'draft7') {
141+
schema.dependencies = deps
142+
} else {
143+
for (const prop in deps) {
144+
const kwd = Array.isArray(deps[prop])
145+
? 'dependentRequired'
146+
: 'dependentSchemas'
147+
schema[kwd] ||= {}
148+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
149+
schema[kwd][prop] = deps[prop]
150+
}
151+
}
152+
break
153+
}
154+
case 'items': {
155+
if (isDraft2020 && Array.isArray(dsCopy.items)) {
156+
schema.prefixItems = dsCopy.items
157+
if (dsCopy.additionalItems !== undefined) {
158+
schema.items = dsCopy.additionalItems
159+
}
160+
} else {
161+
schema.items = dsCopy.items
162+
}
163+
break
164+
}
165+
case 'additionalItems': {
166+
if (!isDraft2020) {
167+
schema.additionalItems = dsCopy.additionalItems
168+
}
169+
break
170+
}
171+
case '$recursiveAnchor': {
172+
if (isDraft2020) {
173+
schema.$dynamicAnchor = 'meta'
174+
} else {
175+
schema.$recursiveAnchor = dsCopy.$recursiveAnchor
176+
}
177+
break
178+
}
179+
default: {
180+
schema[key] = dsCopy[key]
181+
}
182+
}
183+
}
184+
return true
185+
186+
function migrateExclusive(
187+
schema: AnySchemaObject,
188+
key: string,
189+
limit: string,
190+
): void {
191+
if (dsCopy[key] === true) {
192+
schema[key] = dsCopy[limit]
193+
} else if (dsCopy[key] !== false && dsCopy[key] !== undefined) {
194+
ajv!.logger.warn(`${key} is not boolean`)
195+
}
196+
}
197+
},
198+
})
199+
200+
ajvCache[cacheKey] = ajv
201+
202+
return ajv
203+
}

src/common.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import type { AnySchemaObject } from 'ajv'
2+
3+
import {
4+
DRAFT_7_SCHEMA,
5+
DRAFT_2019_SCHEMA,
6+
DRAFT_2020_SCHEMA,
7+
} from './constants.js'
8+
import type { SchemaVersion } from './types.js'
9+
10+
/**
11+
* Recursively evaluates a JSON schema to determine if it reduces to a constant boolean value.
12+
*
13+
* An empty schema (i.e., an object with no keys) is considered to be a constant true schema.
14+
* If the schema has only the "not" property, the function evaluates the value of "not" recursively
15+
* and returns its logical negation when it resolves to a boolean.
16+
*
17+
* @param schema - The JSON schema object to evaluate.
18+
* @returns A boolean indicating the constant result if determinable, or undefined if the schema's
19+
* boolean value cannot be established.
20+
*/
21+
export function constantResultSchema(
22+
schema: AnySchemaObject,
23+
): boolean | undefined {
24+
const keys = Object.keys(schema)
25+
if (keys.length === 0) {
26+
return true
27+
}
28+
if (keys.length === 1 && keys[0] === 'not') {
29+
const valid = constantResultSchema(schema.not as AnySchemaObject)
30+
if (typeof valid == 'boolean') {
31+
return !valid
32+
}
33+
}
34+
}
35+
36+
/**
37+
* Returns the JSON meta-schema corresponding to the specified schema version.
38+
*
39+
* This function selects the appropriate meta-schema constant based on the provided version,
40+
* mapping 'draft7', 'draft2019', and 'draft2020' to their respective meta-schema definitions.
41+
* If an unrecognized version is provided, the function returns undefined.
42+
*
43+
* @param version - The JSON Schema version (e.g., 'draft7', 'draft2019', 'draft2020').
44+
* @returns The meta-schema constant for the specified version, or undefined if the version is not supported.
45+
*/
46+
export function metaSchema(version: SchemaVersion) {
47+
switch (version) {
48+
case 'draft7': {
49+
return DRAFT_7_SCHEMA
50+
}
51+
case 'draft2019': {
52+
return DRAFT_2019_SCHEMA
53+
}
54+
case 'draft2020': {
55+
return DRAFT_2020_SCHEMA
56+
}
57+
}
58+
}

src/constants.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export const DRAFT_4_SCHEMA = 'http://json-schema.org/draft-04/schema'
2+
export const DRAFT_4_SCHEMA_ = 'http://json-schema.org/draft-04/schema#'
3+
4+
export const DRAFT_7_SCHEMA = 'http://json-schema.org/draft-07/schema'
5+
export const DRAFT_7_SCHEMA_ = 'http://json-schema.org/draft-07/schema#'
6+
7+
export const DRAFT_2019_SCHEMA = 'https://json-schema.org/draft/2019-09/schema'
8+
export const DRAFT_2020_SCHEMA = 'https://json-schema.org/draft/2020-12/schema'

src/helpers.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import type { AnySchemaObject, SchemaObject, ValidateFunction } from 'ajv'
2+
3+
import { getAjv } from './ajv.js'
4+
import { metaSchema } from './common.js'
5+
import { DRAFT_2020_SCHEMA, DRAFT_2019_SCHEMA } from './constants.js'
6+
import type { SchemaVersion } from './types.js'
7+
8+
/**
9+
* Generates a migration schema object based on the specified version.
10+
*
11+
* This function constructs a schema object used for validating schema migrations. It selects between
12+
* two draft schemas depending on whether the provided version is 'draft2020' or not, and returns an object
13+
* with a unique `$id`, the chosen schema, and an `allOf` array that combines migration metadata with a reference
14+
* to the base schema. For the 'draft2020' version, a `$dynamicAnchor` is added, whereas for other versions a
15+
* `$recursiveAnchor` property is set.
16+
*
17+
* @param version - The migration schema version (e.g., 'draft2020').
18+
* @returns The migration schema object configured for the supplied version.
19+
*/
20+
export function getMigrateSchema(version: SchemaVersion): SchemaObject {
21+
const isDraft2020 = version === 'draft2020'
22+
const schema = isDraft2020 ? DRAFT_2020_SCHEMA : DRAFT_2019_SCHEMA
23+
return {
24+
$id: `migrateSchema-${version}`,
25+
$schema: schema,
26+
allOf: [{ migrateSchema: version }, { $ref: schema }],
27+
...(isDraft2020 ? { $dynamicAnchor: 'meta' } : { $recursiveAnchor: true }),
28+
}
29+
}
30+
31+
/**
32+
* Returns a schema migration function for the specified version.
33+
*
34+
* The returned function validates a provided schema against a migration schema and ensures its "$schema"
35+
* property is set to the correct meta-schema for the given version. The migration validator is compiled
36+
* on the first invocation and reused for subsequent validations.
37+
*
38+
* @param version - The schema version specifying which migration rules and meta-schema to use.
39+
* @returns A function that, when given a schema object, validates it for migration and updates its "$schema" property.
40+
*/
41+
export function getMigrate(version: SchemaVersion) {
42+
let migrate: ValidateFunction | undefined
43+
return (schema: AnySchemaObject) => {
44+
migrate ||= getAjv(version).compile(getMigrateSchema(version))
45+
migrate(schema)
46+
schema.$schema ||= metaSchema(version)
47+
}
48+
}

0 commit comments

Comments
 (0)