diff --git a/packages/expressions/lib/constant.js b/packages/expressions/lib/constant.js index c42ce6474..8c2fd35de 100644 --- a/packages/expressions/lib/constant.js +++ b/packages/expressions/lib/constant.js @@ -5,23 +5,20 @@ import { Schema } from './schemas' // // Implements the same interface as Expression export class Constant { - constructor (value, id = uuidv4()) { + constructor (value, { id = uuidv4(), schema = Schema.resolve('#/definitions/constant') } = {}) { this.value = value this.id = id + this.schema = schema } - clone (value, id = this.id) { - return new Constant(value, id) + clone (value, { id = this.id, schema = this.schema } = {}) { + return new Constant(value, { id, schema }) } get args () { return [this.value] } - get schema () { - return Schema.resolve('#/definitions/constant') - } - validate (schema = this.schema) { return schema.validate(this.value) } diff --git a/packages/expressions/lib/expression.js b/packages/expressions/lib/expression.js index baba64b1b..3d9b80e09 100644 --- a/packages/expressions/lib/expression.js +++ b/packages/expressions/lib/expression.js @@ -15,7 +15,7 @@ function toArray (arg) { // Simple model to transform this: `{ All: [{ Boolean: [true] }]` // into this: `{ id: uuidv4(), name: 'All', args: [{ id: uuidv4(), name: 'Boolean', args: [true] }] }` export class Expression { - static build (expression) { + static build (expression, schema = undefined) { if (expression instanceof Expression || expression instanceof Constant) { return expression } @@ -25,37 +25,34 @@ export class Expression { throw new TypeError(`Invalid expression: ${JSON.stringify(expression)}`) } const name = Object.keys(expression)[0] - const args = toArray(expression[name]).map(Expression.build) - - return new Expression({ name, args }) + return new Expression({ name, args: expression[name] }) } else if (['number', 'string', 'boolean'].includes(typeof expression)) { - return new Constant(expression) + return new Constant(expression, { schema }) } else { throw new TypeError(`Invalid expression: ${JSON.stringify(expression)}`) } } constructor ({ name, args, id = uuidv4() }) { - Object.assign(this, { name, args, id }) + this.id = id + this.name = name + this.schema = Schema.resolve(`${name}.schema.json`) + this.args = toArray(args).map((arg, i) => Expression.build(arg, this.schema.arrayItem(i))) } clone ({ id = this.id, name = this.name, args = this.args } = {}) { - return new Expression({ id, name, args: args.map(Expression.build) }) + return new Expression({ id, name, args }) } get value () { return { [this.name]: this.args.map(arg => arg.value) } } - get schema () { - return Schema.resolve('#') - } - validate (schema = this.schema) { - return schema.validate(this.value) + return schema.validate(this.args.map(arg => arg.value)) } - matches (localSchema) { - return localSchema.validate(this.value).valid + matches (schema) { + return this.validate(schema).valid } } diff --git a/packages/expressions/lib/schemas.js b/packages/expressions/lib/schemas.js index b642f9d81..2b9127265 100644 --- a/packages/expressions/lib/schemas.js +++ b/packages/expressions/lib/schemas.js @@ -15,35 +15,6 @@ const ajv = new Ajv({ }) addFormats(ajv) -// Proxy to resolve $refs in schema definitions -const Dereference = { - get (target, property) { - const value = target[property] - - if (Array.isArray(value)) { - // Schema definition returns an array for this property, return the array with all refs resolved - return value.map((item, i) => { - const $ref = item.$ref || this.join(target.$id, `${property}/${i}`) - return Schema.resolve($ref, target.$id) - }) - } else if (value !== null && typeof value === 'object') { - // Schema definition returns an object for this property, return the subschema and proxy it - return Schema.proxy(this.join(target.$id, property), value) - } else if (value !== undefined) { - // Schema definition returns a value for this property, just return it - return value - } else if (target.$ref) { - // Schema includes a ref, so delegate to it - return Schema.resolve(target.$ref, target.$id)[property] - } - }, - - join ($id, path) { - const url = new URL($id) - url.hash = [url.hash, path].join('/') - return url.toString() - } -} // Delegate property access to the schema definition const DelegateToDefinition = { @@ -62,7 +33,12 @@ export class Schema { const { href } = new URL($ref, $id) const validator = ajv.getSchema(href) - if (!validator) throw new TypeError('Schema not found: ' + href) + if (validator === undefined) throw new TypeError('Schema not found: ' + href) + + // Schema definition is a primitive, just return it + if (typeof validator.schema !== 'object') return validator.schema + + // Create a new proxy to the schema definition if (!validator.proxy) validator.proxy = Schema.proxy(href, validator.schema) return validator.proxy @@ -73,10 +49,11 @@ export class Schema { } constructor ($id, definition) { - this.definition = new Proxy({ $id, ...definition }, Dereference) + this.$id = $id + this.definition = new Proxy(definition, this) } - resolve ($ref = this.definition.$ref, $id = this.definition.$id) { + resolve ($ref = this.definition.$ref, $id = this.$id) { return Schema.resolve($ref, $id) } @@ -90,11 +67,42 @@ export class Schema { } validate (data) { - const validator = ajv.getSchema(this.definition.$id) + const validator = ajv.getSchema(this.$id) const valid = validator(data) const errors = validator.errors return { valid, errors } } + + // This instance acts as a Proxy to resolve $refs in the schema definition + get (target, property) { + const value = target[property] + + if (Array.isArray(value)) { + // Schema definition returns an array for this property, return the array with all refs resolved + return value.map((item, i) => { + if (typeof item === 'object') { + return Schema.resolve(item.$ref || this.join(`${property}/${i}`), this.$id) + } else { + return item + } + }) + } else if (value !== null && typeof value === 'object') { + // Schema definition returns an object for this property, return the subschema and proxy it + return Schema.proxy(this.join(property), value) + } else if (value !== undefined) { + // Schema definition returns a value for this property, just return it + return value + } else if (target.$ref) { + // Schema includes a ref, so delegate to it + return Schema.resolve(target.$ref, this.$id)[property] + } + } + + join (path, $id = this.$id) { + const url = new URL($id) + url.hash = [url.hash, path].join('/') + return url.toString() + } } export const schema = Schema.resolve('#') diff --git a/packages/expressions/test/constant.test.js b/packages/expressions/test/constant.test.js index 715ed4f12..1981f5c1d 100644 --- a/packages/expressions/test/constant.test.js +++ b/packages/expressions/test/constant.test.js @@ -3,9 +3,16 @@ import { Constant, Schema } from '../lib' describe('Constant', () => { describe('schema', () => { - test('returns Constant schema', () => { + test('defaults to Constant schema', () => { expect(new Constant('string').schema.title).toEqual('Constant') }) + + test('uses provided schema', () => { + const schema = Schema.resolve('#/definitions/number') + const number = new Constant(42, { schema }) + expect(number.schema).toEqual(schema) + expect(number.clone(99).schema).toEqual(schema) + }) }) describe('validate', () => { @@ -16,6 +23,20 @@ describe('Constant', () => { expect(new Constant(42).validate().valid).toBe(true) expect(new Constant(3.14).validate().valid).toBe(true) }) + + test('returns false for invalid value', () => { + expect(new Constant(['array']).validate().valid).toBe(false) + expect(new Constant({Now: []}).validate().valid).toBe(false) + }) + + test('uses provided schema', () => { + const schema = Schema.resolve('#/definitions/number') + expect(new Constant(42, { schema }).validate().valid).toBe(true) + expect(new Constant(42).validate(schema).valid).toBe(true) + + expect(new Constant('nope', { schema }).validate().valid).toBe(false) + expect(new Constant('nope').validate(schema).valid).toBe(false) + }) }) describe('matches', () => { diff --git a/packages/expressions/test/expression.test.js b/packages/expressions/test/expression.test.js index 37e7ed06f..bd96cde76 100644 --- a/packages/expressions/test/expression.test.js +++ b/packages/expressions/test/expression.test.js @@ -1,5 +1,5 @@ import { describe, test, expect } from 'vitest' -import { Expression, Constant } from '../lib' +import { Expression, Constant, Schema } from '../lib' describe('Expression', () => { describe('build', () => { @@ -16,6 +16,21 @@ describe('Expression', () => { expect(() => Expression.build(new Date())).toThrowError(TypeError) expect(() => Expression.build({ All: [], Any: [] })).toThrowError(TypeError) }) + + test('sets schema for constant args', () => { + const expression = Expression.build({ Duration: [5, 'minutes'] }) + const schema = Schema.resolve('Duration.schema.json') + expect(expression.schema).toEqual(schema) + expect(expression.args[0].schema).toEqual(schema.items[0]) + expect(expression.args[1].schema).toEqual(schema.items[1]) + }) + + test('each subexpression uses its own schema', () => { + const expression = Expression.build({ GreaterThan: [ { Now: [] }, { Property: ['released_at'] } ] }) + expect(expression.schema).toEqual(Schema.resolve('GreaterThan.schema.json')) + expect(expression.args[0].schema).toEqual(Schema.resolve('Now.schema.json')) + expect(expression.args[1].schema).toEqual(Schema.resolve('Property.schema.json')) + }) }) describe('clone', () => { diff --git a/packages/expressions/test/schemas.test.js b/packages/expressions/test/schemas.test.js index d40c979ec..cff727813 100644 --- a/packages/expressions/test/schemas.test.js +++ b/packages/expressions/test/schemas.test.js @@ -45,6 +45,11 @@ describe('schema.json', () => { expect(Schema.resolve('#/definitions/function/properties/Any').title).toEqual('Any') expect(Schema.resolve('#').definitions.function.properties.Any.title).toEqual('Any') }) + + test('returns array values', () => { + const expected = ['seconds', 'minutes', 'hours', 'days', 'weeks', 'months', 'years'] + expect(Schema.resolve('Duration.schema.json#/items/1/anyOf/0').enum).toEqual(expected) + }) }) describe('resolveAnyOf', () => { @@ -69,8 +74,8 @@ describe('schema.json', () => { test('returns schema for tuple', () => { const duration = Schema.resolve('Duration.schema.json') - expect(duration.arrayItem(0).title).toEqual('Number') - expect(duration.arrayItem(1).title).toEqual('Unit') + expect(duration.arrayItem(0).$id).toMatch('schema.json#/definitions/number') + expect(duration.arrayItem(1).$id).toMatch('Duration.schema.json#/items/1') expect(duration.arrayItem(2)).toBe(undefined) }) })