Skip to content

Commit

Permalink
Pair schema with expression and constant
Browse files Browse the repository at this point in the history
  • Loading branch information
bkeepers committed Apr 12, 2023
1 parent 7c5016b commit 26a08f3
Show file tree
Hide file tree
Showing 6 changed files with 101 additions and 58 deletions.
11 changes: 4 additions & 7 deletions packages/expressions/lib/constant.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
25 changes: 11 additions & 14 deletions packages/expressions/lib/expression.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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
}
}
74 changes: 41 additions & 33 deletions packages/expressions/lib/schemas.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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
Expand All @@ -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)
}

Expand All @@ -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('#')
23 changes: 22 additions & 1 deletion packages/expressions/test/constant.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -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', () => {
Expand Down
17 changes: 16 additions & 1 deletion packages/expressions/test/expression.test.js
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand All @@ -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', () => {
Expand Down
9 changes: 7 additions & 2 deletions packages/expressions/test/schemas.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -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)
})
})
Expand Down

0 comments on commit 26a08f3

Please sign in to comment.