diff --git a/ext/js/data/json-schema.js b/ext/js/data/json-schema.js index bd6d9022..a6306c3a 100644 --- a/ext/js/data/json-schema.js +++ b/ext/js/data/json-schema.js @@ -25,6 +25,7 @@ class JsonSchema { this._startSchema = schema; this._rootSchema = typeof rootSchema !== 'undefined' ? rootSchema : schema; this._regexCache = null; + this._refCache = null; this._valueStack = []; this._schemaStack = []; @@ -73,7 +74,8 @@ class JsonSchema { } getObjectPropertySchema(property) { - this._schemaPush(this._startSchema, null); + const startSchemaInfo = this._getResolveSchemaInfo({schema: this._startSchema, path: null}); + this._schemaPush(startSchemaInfo.schema, startSchemaInfo.path); try { const schemaInfo = this._getObjectPropertySchemaInfo(property); return schemaInfo !== null ? new JsonSchema(schemaInfo.schema, this._rootSchema) : null; @@ -83,7 +85,8 @@ class JsonSchema { } getArrayItemSchema(index) { - this._schemaPush(this._startSchema, null); + const startSchemaInfo = this._getResolveSchemaInfo({schema: this._startSchema, path: null}); + this._schemaPush(startSchemaInfo.schema, startSchemaInfo.path); try { const schemaInfo = this._getArrayItemSchemaInfo(index); return schemaInfo !== null ? new JsonSchema(schemaInfo.schema, this._rootSchema) : null; @@ -267,6 +270,71 @@ class JsonSchema { return value1 === value2; } + _getResolveSchemaInfo(schemaInfo) { + const ref = schemaInfo.schema.$ref; + if (typeof ref !== 'string') { return schemaInfo; } + + const {path: basePath} = schemaInfo; + const {schema, path} = this._getReference(ref); + if (Array.isArray(basePath)) { + path.unshift(...basePath); + } else { + path.unshift(basePath); + } + return {schema, path}; + } + + _getReference(ref) { + if (!ref.startsWith('#/')) { + throw this._createError(`Unsupported reference path: ${ref}`); + } + + let info; + if (this._refCache !== null) { + info = this._refCache.get(ref); + } else { + this._refCache = new Map(); + } + + if (typeof info === 'undefined') { + info = this._getReferenceUncached(ref); + this._refCache.set(ref, info); + } + + return {schema: info.schema, path: [...info.path]}; + } + + _getReferenceUncached(ref) { + const visited = new Set(); + const path = []; + while (true) { + if (visited.has(ref)) { + throw this._createError(`Recursive reference: ${ref}`); + } + visited.add(ref); + + const pathParts = ref.substring(2).split('/'); + let schema = this._rootSchema; + try { + for (const pathPart of pathParts) { + schema = schema[pathPart]; + } + } catch (e) { + throw this._createError(`Invalid reference: ${ref}`); + } + if (!this._isObject(schema)) { + throw this._createError(`Invalid reference: ${ref}`); + } + + path.push(null, ...pathParts); + + ref = schema.$ref; + if (typeof ref !== 'string') { + return {schema, path}; + } + } + } + // Validation _isValidCurrent(value) { @@ -279,6 +347,22 @@ class JsonSchema { } _validate(value) { + const ref = this._schema.$ref; + const schemaInfo = (typeof ref === 'string') ? this._getReference(ref) : null; + + if (schemaInfo === null) { + this._validateInner(value); + } else { + this._schemaPush(schemaInfo.schema, schemaInfo.path); + try { + this._validateInner(value); + } finally { + this._schemaPop(); + } + } + } + + _validateInner(value) { this._validateSingleSchema(value); this._validateConditional(value); this._validateAllOf(value); @@ -638,6 +722,7 @@ class JsonSchema { } _getValidValueOrDefault(path, value, schemaInfo) { + schemaInfo = this._getResolveSchemaInfo(schemaInfo); this._schemaPush(schemaInfo.schema, schemaInfo.path); this._valuePush(value, path); try { diff --git a/test/test-json-schema.js b/test/test-json-schema.js index ba0131bf..c5ff830a 100644 --- a/test/test-json-schema.js +++ b/test/test-json-schema.js @@ -413,6 +413,80 @@ function testValidate2() { {expected: false, value: []}, {expected: false, value: {}} ] + }, + + // Reference tests + { + schema: { + definitions: { + example: { + type: 'number' + } + }, + $ref: '#/definitions/example' + }, + inputs: [ + {expected: true, value: 0}, + {expected: true, value: 0.5}, + {expected: true, value: 1}, + {expected: false, value: '0'}, + {expected: false, value: null}, + {expected: false, value: []}, + {expected: false, value: {}} + ] + }, + { + schema: { + definitions: { + example: { + type: 'integer' + } + }, + $ref: '#/definitions/example' + }, + inputs: [ + {expected: true, value: 0}, + {expected: false, value: 0.5}, + {expected: true, value: 1}, + {expected: false, value: '0'}, + {expected: false, value: null}, + {expected: false, value: []}, + {expected: false, value: {}} + ] + }, + { + schema: { + definitions: { + example: { + type: 'object', + additionalProperties: false, + properties: { + test: { + $ref: '#/definitions/example' + } + } + } + }, + $ref: '#/definitions/example' + }, + inputs: [ + {expected: false, value: 0}, + {expected: false, value: 0.5}, + {expected: false, value: 1}, + {expected: false, value: '0'}, + {expected: false, value: null}, + {expected: false, value: []}, + {expected: true, value: {}}, + {expected: false, value: {test: 0}}, + {expected: false, value: {test: 0.5}}, + {expected: false, value: {test: 1}}, + {expected: false, value: {test: '0'}}, + {expected: false, value: {test: null}}, + {expected: false, value: {test: []}}, + {expected: true, value: {test: {}}}, + {expected: true, value: {test: {test: {}}}}, + {expected: true, value: {test: {test: {test: {}}}}} + ] } ]; @@ -690,6 +764,83 @@ function testGetValidValueOrDefault1() { {test: -1} ] ] + }, + + // Test references + { + schema: { + definitions: { + example: { + type: 'number', + default: 0 + } + }, + $ref: '#/definitions/example' + }, + inputs: [ + [ + 1, + 1 + ], + [ + null, + 0 + ], + [ + 'test', + 0 + ], + [ + {test: 'value'}, + 0 + ] + ] + }, + { + schema: { + definitions: { + example: { + type: 'object', + additionalProperties: false, + properties: { + test: { + $ref: '#/definitions/example' + } + } + } + }, + $ref: '#/definitions/example' + }, + inputs: [ + [ + 1, + {} + ], + [ + null, + {} + ], + [ + 'test', + {} + ], + [ + {}, + {} + ], + [ + {test: {}}, + {test: {}} + ], + [ + {test: 'value'}, + {test: {}} + ], + [ + {test: {test: {}}}, + {test: {test: {}}} + ] + ] } ]; @@ -797,6 +948,33 @@ function testProxy1() { {error: true, value: ['default'], action: (value) => { delete value[0]; }}, {error: false, value: ['default'], action: (value) => { value[1] = 'string'; }} ] + }, + + // Reference tests + { + schema: { + definitions: { + example: { + type: 'object', + additionalProperties: false, + properties: { + test: { + $ref: '#/definitions/example' + } + } + } + }, + $ref: '#/definitions/example' + }, + tests: [ + {error: false, value: {}, action: (value) => { value.test = {}; }}, + {error: false, value: {}, action: (value) => { value.test = {}; value.test.test = {}; }}, + {error: false, value: {}, action: (value) => { value.test = {test: {}}; }}, + {error: true, value: {}, action: (value) => { value.test = null; }}, + {error: true, value: {}, action: (value) => { value.test = 'string'; }}, + {error: true, value: {}, action: (value) => { value.test = {}; value.test.test = 'string'; }}, + {error: true, value: {}, action: (value) => { value.test = {test: 'string'}; }} + ] } ];