Skip to content

Commit be8a651

Browse files
committed
Pair schema with expression and constant
1 parent b423e57 commit be8a651

File tree

6 files changed

+101
-58
lines changed

6 files changed

+101
-58
lines changed

lib/constant.js

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,23 +5,20 @@ import { Schema } from './schemas'
55
//
66
// Implements the same interface as Expression
77
export class Constant {
8-
constructor (value, id = uuidv4()) {
8+
constructor (value, { id = uuidv4(), schema = Schema.resolve('#/definitions/constant') } = {}) {
99
this.value = value
1010
this.id = id
11+
this.schema = schema
1112
}
1213

13-
clone (value, id = this.id) {
14-
return new Constant(value, id)
14+
clone (value, { id = this.id, schema = this.schema } = {}) {
15+
return new Constant(value, { id, schema })
1516
}
1617

1718
get args () {
1819
return [this.value]
1920
}
2021

21-
get schema () {
22-
return Schema.resolve('#/definitions/constant')
23-
}
24-
2522
validate (schema = this.schema) {
2623
return schema.validate(this.value)
2724
}

lib/expression.js

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ function toArray (arg) {
1515
// Simple model to transform this: `{ All: [{ Boolean: [true] }]`
1616
// into this: `{ id: uuidv4(), name: 'All', args: [{ id: uuidv4(), name: 'Boolean', args: [true] }] }`
1717
export class Expression {
18-
static build (expression) {
18+
static build (expression, schema = undefined) {
1919
if (expression instanceof Expression || expression instanceof Constant) {
2020
return expression
2121
}
@@ -25,37 +25,34 @@ export class Expression {
2525
throw new TypeError(`Invalid expression: ${JSON.stringify(expression)}`)
2626
}
2727
const name = Object.keys(expression)[0]
28-
const args = toArray(expression[name]).map(Expression.build)
29-
30-
return new Expression({ name, args })
28+
return new Expression({ name, args: expression[name] })
3129
} else if (['number', 'string', 'boolean'].includes(typeof expression)) {
32-
return new Constant(expression)
30+
return new Constant(expression, { schema })
3331
} else {
3432
throw new TypeError(`Invalid expression: ${JSON.stringify(expression)}`)
3533
}
3634
}
3735

3836
constructor ({ name, args, id = uuidv4() }) {
39-
Object.assign(this, { name, args, id })
37+
this.id = id
38+
this.name = name
39+
this.schema = Schema.resolve(`${name}.schema.json`)
40+
this.args = toArray(args).map((arg, i) => Expression.build(arg, this.schema.arrayItem(i)))
4041
}
4142

4243
clone ({ id = this.id, name = this.name, args = this.args } = {}) {
43-
return new Expression({ id, name, args: args.map(Expression.build) })
44+
return new Expression({ id, name, args })
4445
}
4546

4647
get value () {
4748
return { [this.name]: this.args.map(arg => arg.value) }
4849
}
4950

50-
get schema () {
51-
return Schema.resolve('#')
52-
}
53-
5451
validate (schema = this.schema) {
55-
return schema.validate(this.value)
52+
return schema.validate(this.args.map(arg => arg.value))
5653
}
5754

58-
matches (localSchema) {
59-
return localSchema.validate(this.value).valid
55+
matches (schema) {
56+
return this.validate(schema).valid
6057
}
6158
}

lib/schemas.js

Lines changed: 41 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -15,35 +15,6 @@ const ajv = new Ajv({
1515
})
1616
addFormats(ajv)
1717

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) => {
26-
const $ref = item.$ref || this.join(target.$id, `${property}/${i}`)
27-
return Schema.resolve($ref, target.$id)
28-
})
29-
} else if (value !== null && typeof value === 'object') {
30-
// Schema definition returns an object for this property, return the subschema and proxy it
31-
return Schema.proxy(this.join(target.$id, property), value)
32-
} else if (value !== undefined) {
33-
// Schema definition returns a value for this property, just return it
34-
return value
35-
} else if (target.$ref) {
36-
// Schema includes a ref, so delegate to it
37-
return Schema.resolve(target.$ref, target.$id)[property]
38-
}
39-
},
40-
41-
join ($id, path) {
42-
const url = new URL($id)
43-
url.hash = [url.hash, path].join('/')
44-
return url.toString()
45-
}
46-
}
4718

4819
// Delegate property access to the schema definition
4920
const DelegateToDefinition = {
@@ -62,7 +33,12 @@ export class Schema {
6233
const { href } = new URL($ref, $id)
6334
const validator = ajv.getSchema(href)
6435

65-
if (!validator) throw new TypeError('Schema not found: ' + href)
36+
if (validator === undefined) throw new TypeError('Schema not found: ' + href)
37+
38+
// Schema definition is a primitive, just return it
39+
if (typeof validator.schema !== 'object') return validator.schema
40+
41+
// Create a new proxy to the schema definition
6642
if (!validator.proxy) validator.proxy = Schema.proxy(href, validator.schema)
6743

6844
return validator.proxy
@@ -73,10 +49,11 @@ export class Schema {
7349
}
7450

7551
constructor ($id, definition) {
76-
this.definition = new Proxy({ $id, ...definition }, Dereference)
52+
this.$id = $id
53+
this.definition = new Proxy(definition, this)
7754
}
7855

79-
resolve ($ref = this.definition.$ref, $id = this.definition.$id) {
56+
resolve ($ref = this.definition.$ref, $id = this.$id) {
8057
return Schema.resolve($ref, $id)
8158
}
8259

@@ -90,11 +67,42 @@ export class Schema {
9067
}
9168

9269
validate (data) {
93-
const validator = ajv.getSchema(this.definition.$id)
70+
const validator = ajv.getSchema(this.$id)
9471
const valid = validator(data)
9572
const errors = validator.errors
9673
return { valid, errors }
9774
}
75+
76+
// This instance acts as a Proxy to resolve $refs in the schema definition
77+
get (target, property) {
78+
const value = target[property]
79+
80+
if (Array.isArray(value)) {
81+
// Schema definition returns an array for this property, return the array with all refs resolved
82+
return value.map((item, i) => {
83+
if (typeof item === 'object') {
84+
return Schema.resolve(item.$ref || this.join(`${property}/${i}`), this.$id)
85+
} else {
86+
return item
87+
}
88+
})
89+
} else if (value !== null && typeof value === 'object') {
90+
// Schema definition returns an object for this property, return the subschema and proxy it
91+
return Schema.proxy(this.join(property), value)
92+
} else if (value !== undefined) {
93+
// Schema definition returns a value for this property, just return it
94+
return value
95+
} else if (target.$ref) {
96+
// Schema includes a ref, so delegate to it
97+
return Schema.resolve(target.$ref, this.$id)[property]
98+
}
99+
}
100+
101+
join (path, $id = this.$id) {
102+
const url = new URL($id)
103+
url.hash = [url.hash, path].join('/')
104+
return url.toString()
105+
}
98106
}
99107

100108
export const schema = Schema.resolve('#')

test/constant.test.js

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,16 @@ import { Constant, Schema } from '../lib'
33

44
describe('Constant', () => {
55
describe('schema', () => {
6-
test('returns Constant schema', () => {
6+
test('defaults to Constant schema', () => {
77
expect(new Constant('string').schema.title).toEqual('Constant')
88
})
9+
10+
test('uses provided schema', () => {
11+
const schema = Schema.resolve('#/definitions/number')
12+
const number = new Constant(42, { schema })
13+
expect(number.schema).toEqual(schema)
14+
expect(number.clone(99).schema).toEqual(schema)
15+
})
916
})
1017

1118
describe('validate', () => {
@@ -16,6 +23,20 @@ describe('Constant', () => {
1623
expect(new Constant(42).validate().valid).toBe(true)
1724
expect(new Constant(3.14).validate().valid).toBe(true)
1825
})
26+
27+
test('returns false for invalid value', () => {
28+
expect(new Constant(['array']).validate().valid).toBe(false)
29+
expect(new Constant({Now: []}).validate().valid).toBe(false)
30+
})
31+
32+
test('uses provided schema', () => {
33+
const schema = Schema.resolve('#/definitions/number')
34+
expect(new Constant(42, { schema }).validate().valid).toBe(true)
35+
expect(new Constant(42).validate(schema).valid).toBe(true)
36+
37+
expect(new Constant('nope', { schema }).validate().valid).toBe(false)
38+
expect(new Constant('nope').validate(schema).valid).toBe(false)
39+
})
1940
})
2041

2142
describe('matches', () => {

test/expression.test.js

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, test, expect } from 'vitest'
2-
import { Expression, Constant } from '../lib'
2+
import { Expression, Constant, Schema } from '../lib'
33

44
describe('Expression', () => {
55
describe('build', () => {
@@ -16,6 +16,21 @@ describe('Expression', () => {
1616
expect(() => Expression.build(new Date())).toThrowError(TypeError)
1717
expect(() => Expression.build({ All: [], Any: [] })).toThrowError(TypeError)
1818
})
19+
20+
test('sets schema for constant args', () => {
21+
const expression = Expression.build({ Duration: [5, 'minutes'] })
22+
const schema = Schema.resolve('Duration.schema.json')
23+
expect(expression.schema).toEqual(schema)
24+
expect(expression.args[0].schema).toEqual(schema.items[0])
25+
expect(expression.args[1].schema).toEqual(schema.items[1])
26+
})
27+
28+
test('each subexpression uses its own schema', () => {
29+
const expression = Expression.build({ GreaterThan: [ { Now: [] }, { Property: ['released_at'] } ] })
30+
expect(expression.schema).toEqual(Schema.resolve('GreaterThan.schema.json'))
31+
expect(expression.args[0].schema).toEqual(Schema.resolve('Now.schema.json'))
32+
expect(expression.args[1].schema).toEqual(Schema.resolve('Property.schema.json'))
33+
})
1934
})
2035

2136
describe('clone', () => {

test/schemas.test.js

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,11 @@ describe('schema.json', () => {
4545
expect(Schema.resolve('#/definitions/function/properties/Any').title).toEqual('Any')
4646
expect(Schema.resolve('#').definitions.function.properties.Any.title).toEqual('Any')
4747
})
48+
49+
test('returns array values', () => {
50+
const expected = ['seconds', 'minutes', 'hours', 'days', 'weeks', 'months', 'years']
51+
expect(Schema.resolve('Duration.schema.json#/items/1/anyOf/0').enum).toEqual(expected)
52+
})
4853
})
4954

5055
describe('resolveAnyOf', () => {
@@ -69,8 +74,8 @@ describe('schema.json', () => {
6974

7075
test('returns schema for tuple', () => {
7176
const duration = Schema.resolve('Duration.schema.json')
72-
expect(duration.arrayItem(0).title).toEqual('Number')
73-
expect(duration.arrayItem(1).title).toEqual('Unit')
77+
expect(duration.arrayItem(0).$id).toMatch('schema.json#/definitions/number')
78+
expect(duration.arrayItem(1).$id).toMatch('Duration.schema.json#/items/1')
7479
expect(duration.arrayItem(2)).toBe(undefined)
7580
})
7681
})

0 commit comments

Comments
 (0)