Skip to content

Commit 9fa9916

Browse files
committed
Add proxying back to schema to resolve refs
1 parent 0bda019 commit 9fa9916

File tree

7 files changed

+127
-36
lines changed

7 files changed

+127
-36
lines changed

lib/constant.js

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,15 @@ export class Constant {
1212
return [this.value]
1313
}
1414

15-
get validator () {
16-
return schema.get('#/definitions/constant')
15+
get schema () {
16+
return schema.resolve('#/definitions/constant')
1717
}
1818

19-
validate (validator = this.validator) {
20-
return schema.validate(this.value, validator)
19+
validate (schema = this.schema) {
20+
return schema.validate(this.value)
2121
}
2222

23-
matches (localSchema) {
24-
return this.validate(localSchema).valid
23+
matches (schema = this.schema) {
24+
return schema.validate(this.value).valid
2525
}
2626
}

lib/expression.js

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -47,15 +47,15 @@ export class Expression {
4747
return { [this.name]: this.args.map(arg => arg.value) }
4848
}
4949

50-
get validator () {
51-
return schema.get('#')
50+
get schema () {
51+
return schema.resolve('#')
5252
}
5353

54-
validate (validator = this.validator) {
55-
return schema.validate(this.value, validator)
54+
validate (schema = this.schema) {
55+
return schema.validate(this.value)
5656
}
5757

5858
matches (localSchema) {
59-
return this.validate(localSchema).valid
59+
return localSchema.validate(this.value).valid
6060
}
6161
}

lib/schemas.js

Lines changed: 74 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,92 @@
11
import Ajv from 'ajv'
22
import addFormats from 'ajv-formats'
33

4+
// Load all schemas in schemas/*.json
45
const modules = import.meta.glob('../schemas/*.json', { eager: true, import: 'default' })
56
export const schemas = Object.values(modules)
67
export const BaseURI = modules['../schemas/schema.json'].$id
78

9+
// Create a new Ajv validator instance with all schemas loaded
10+
const ajv = new Ajv({
11+
schemas,
12+
useDefaults: true,
13+
allErrors: true,
14+
strict: true
15+
})
16+
addFormats(ajv)
17+
18+
// Proxy to resolve $refs in schema definitions
19+
const Dereference = {
20+
get (target, property) {
21+
const value = target[property]
22+
23+
if (Array.isArray(value)) {
24+
// Schema definition returns an array for this property, return the array with all refs resolved
25+
return value.map((item, i) => Schema.proxy(this.join(target.$id, `${property}/${i}`), item))
26+
} else if (value !== null && typeof value === 'object') {
27+
// Schema definition returns an object for this property, return the subschema and proxy it
28+
return Schema.proxy(this.join(target.$id, property), value)
29+
} else if (value !== undefined) {
30+
// Schema definition returns a value for this property, just return it
31+
return value
32+
} else if (target.$ref) {
33+
// Schema includes a ref, so delegate to it
34+
return Schema.resolve(target.$ref, target.$id)[property]
35+
}
36+
},
37+
38+
join ($id, path) {
39+
const url = new URL($id)
40+
url.hash = [url.hash, path].join('/')
41+
return url.toString()
42+
}
43+
}
44+
45+
// Delegate property access to the schema definition
46+
const DelegateToDefinition = {
47+
get (target, property) {
48+
return target[property] ?? target.definition[property]
49+
},
50+
51+
has (target, property) {
52+
return property in target || property in target.definition
53+
}
54+
}
55+
56+
// Class to browse schemas, resolve refs, and validate data
857
class Schema {
9-
constructor (schemas, baseURI) {
10-
this.baseURI = baseURI
58+
static resolve ($ref, $id = undefined) {
59+
const { href } = new URL($ref, $id)
60+
return this.proxy(href, ajv.getSchema(href).schema)
61+
}
62+
63+
static proxy ($id, definition) {
64+
return new Proxy(new Schema($id, definition), DelegateToDefinition)
65+
}
66+
67+
constructor ($id, definition) {
68+
this.definition = new Proxy({ $id, ...definition }, Dereference)
69+
}
70+
71+
resolve ($ref = this.definition.$ref, $id = this.definition.$id) {
72+
return Schema.resolve($ref, $id)
73+
}
1174

12-
this.ajv = new Ajv({
13-
schemas,
14-
useDefaults: true,
15-
allErrors: true,
16-
strict: true
17-
})
18-
addFormats(this.ajv)
75+
resolveAnyOf () {
76+
return this.definition.anyOf?.map(ref => ref.resolveAnyOf())?.flat() || [this]
1977
}
2078

21-
get (ref, baseURI = this.baseURI) {
22-
return this.ajv.getSchema(new URL(ref, baseURI).href)
79+
arrayItem (index) {
80+
const items = this.definition.items
81+
return Array.isArray(items) ? items[index] : items
2382
}
2483

25-
validate (data, validator = this.get('#')) {
26-
const valid = validator(data, schema)
84+
validate (data) {
85+
const validator = ajv.getSchema(this.definition.$id)
86+
const valid = validator(data)
2787
const errors = validator.errors
2888
return { valid, errors }
2989
}
3090
}
3191

32-
export const schema = new Schema(schemas, BaseURI)
92+
export const schema = Schema.resolve(BaseURI)

schemas/Duration.schema.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
"items": [
88
{ "$ref": "schema.json#/definitions/number" },
99
{
10+
"title": "Unit",
1011
"anyOf": [
1112
{
1213
"type": "string",

schemas/schema.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,13 +57,15 @@
5757
"additionalProperties": false
5858
},
5959
"string": {
60+
"title": "String",
6061
"description": "A constant string value or a function that returns a string",
6162
"anyOf": [
6263
{ "type": "string" },
6364
{ "$ref": "#/definitions/function" }
6465
]
6566
},
6667
"number": {
68+
"title": "Number",
6769
"description": "A constant numeric value or a function that returns a number",
6870
"anyOf": [
6971
{ "type": "number" },

test/constant.test.js

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@ import { describe, test, expect } from 'vitest'
22
import { Constant, schema } from '../lib'
33

44
describe('Constant', () => {
5-
describe('validator', () => {
6-
test('returns Constant validator', () => {
7-
expect(new Constant('string').validator.schema.title).toEqual('Constant')
5+
describe('schema', () => {
6+
test('returns Constant schema', () => {
7+
expect(new Constant('string').schema.title).toEqual('Constant')
88
})
99
})
1010

@@ -20,12 +20,12 @@ describe('Constant', () => {
2020

2121
describe('matches', () => {
2222
test('returns true for matching validator', () => {
23-
const validator = schema.get('#/definitions/constant/anyOf/0')
23+
const validator = schema.resolve('#/definitions/constant/anyOf/0')
2424
expect(new Constant('string').matches(validator)).toBe(true)
2525
})
2626

2727
test('returns false for different schema', () => {
28-
const validator = schema.get('#/definitions/constant/anyOf/0')
28+
const validator = schema.resolve('#/definitions/constant/anyOf/0')
2929
expect(new Constant(true).matches(validator)).toBe(false)
3030
})
3131
})

test/schemas.test.js

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,15 +34,43 @@ describe('schema.json', () => {
3434
})
3535
}
3636

37-
describe('get', () => {
38-
test('returns a validator', () => {
39-
const ref = schema.get('#')
40-
expect(ref.schema.title).toEqual('Expression')
37+
describe('resolve', () => {
38+
test('returns a schema', () => {
39+
const ref = schema.resolve('#/definitions/constant')
40+
expect(ref.title).toEqual('Constant')
41+
expect(ref.validate(true)).toEqual({ valid: true, errors: null })
4142
})
4243

4344
test('resolves refs', () => {
44-
const ref = schema.get('#/definitions/function/properties/Any')
45-
expect(ref.schema.title).toEqual('Any')
45+
expect(schema.resolve('#/definitions/function/properties/Any').title).toEqual('Any')
46+
expect(schema.definitions.function.properties.Any.title).toEqual('Any')
47+
})
48+
})
49+
50+
describe('resolveAnyOf', () => {
51+
test('returns nested anyOf', () => {
52+
expect(schema.resolveAnyOf()).toHaveLength(4)
53+
})
54+
55+
test('returns array of schemas', () => {
56+
const ref = schema.resolve('#/definitions/constant')
57+
expect(ref.resolveAnyOf()).toHaveLength(3)
58+
expect(ref.resolveAnyOf()).toEqual(ref.anyOf)
59+
})
60+
})
61+
62+
describe('arrayItem', () => {
63+
test('returns schema for repeated array item', () => {
64+
const any = schema.resolve("Any.schema.json")
65+
expect(any.arrayItem(0).title).toEqual('Expression')
66+
expect(any.arrayItem(99).title).toEqual('Expression')
67+
})
68+
69+
test('returns schema for tuple', () => {
70+
const duration = schema.resolve("Duration.schema.json")
71+
expect(duration.arrayItem(0).title).toEqual('Number')
72+
expect(duration.arrayItem(1).title).toEqual('Unit')
73+
expect(duration.arrayItem(2)).toBe(undefined)
4674
})
4775
})
4876
})

0 commit comments

Comments
 (0)