From d582c7a0f856c1a352992e3d16be319a891e0202 Mon Sep 17 00:00:00 2001 From: toasted-nutbread Date: Sat, 15 Aug 2020 17:23:09 -0400 Subject: [PATCH] JSON schema refactor (#731) * Remove JsonSchema.clone * Move createProxy function * Group public properties first * Create private version of getPropertySchema * Mark functions as private * Use non-static getValidValueOrDefault * Mark private * Make public validate function not take an info parameter * Remove JsonSchema * Add isValid function * Use isValid for some tests * Fix incorrect type --- ext/bg/js/backend.js | 9 +- ext/bg/js/dictionary-importer.js | 5 +- ext/bg/js/json-schema.js | 245 ++++++++++++++++--------------- test/dictionary-validate.js | 6 +- test/schema-validate.js | 4 +- test/test-schema.js | 18 +-- 6 files changed, 142 insertions(+), 145 deletions(-) diff --git a/ext/bg/js/backend.js b/ext/bg/js/backend.js index ba87a560..65f4d6b3 100644 --- a/ext/bg/js/backend.js +++ b/ext/bg/js/backend.js @@ -24,7 +24,7 @@ * DictionaryDatabase * DictionaryImporter * Environment - * JsonSchema + * JsonSchemaValidator * Mecab * ObjectPropertyAccessor * OptionsUtil @@ -48,6 +48,7 @@ class Backend { this._clipboardMonitor = new ClipboardMonitor({getClipboard: this._onApiClipboardGet.bind(this)}); this._options = null; this._optionsSchema = null; + this._optionsSchemaValidator = new JsonSchemaValidator(); this._defaultAnkiFieldTemplates = null; this._requestBuilder = new RequestBuilder(); this._audioUriBuilder = new AudioUriBuilder({ @@ -204,7 +205,7 @@ class Backend { this._optionsSchema = await this._fetchAsset('/bg/data/options-schema.json', true); this._defaultAnkiFieldTemplates = (await this._fetchAsset('/bg/data/default-anki-field-templates.handlebars')).trim(); this._options = await OptionsUtil.load(); - this._options = JsonSchema.getValidValueOrDefault(this._optionsSchema, this._options); + this._options = this._optionsSchemaValidator.getValidValueOrDefault(this._optionsSchema, this._options); this._applyOptions('background'); @@ -235,7 +236,7 @@ class Backend { getFullOptions(useSchema=false) { const options = this._options; - return useSchema ? JsonSchema.createProxy(options, this._optionsSchema) : options; + return useSchema ? this._optionsSchemaValidator.createProxy(options, this._optionsSchema) : options; } getOptions(optionsContext, useSchema=false) { @@ -792,7 +793,7 @@ class Backend { } async _onApiSetAllSettings({value, source}) { - this._options = JsonSchema.getValidValueOrDefault(this._optionsSchema, value); + this._options = this._optionsSchemaValidator.getValidValueOrDefault(this._optionsSchema, value); await this._onApiOptionsSave({source}); } diff --git a/ext/bg/js/dictionary-importer.js b/ext/bg/js/dictionary-importer.js index 4374ff40..535756f7 100644 --- a/ext/bg/js/dictionary-importer.js +++ b/ext/bg/js/dictionary-importer.js @@ -17,13 +17,14 @@ /* global * JSZip - * JsonSchema + * JsonSchemaValidator * mediaUtility */ class DictionaryImporter { constructor() { this._schemas = new Map(); + this._jsonSchemaValidator = new JsonSchemaValidator(); } async importDictionary(dictionaryDatabase, archiveSource, details, onProgress) { @@ -241,7 +242,7 @@ class DictionaryImporter { _validateJsonSchema(value, schema, fileName) { try { - JsonSchema.validate(value, schema); + this._jsonSchemaValidator.validate(value, schema); } catch (e) { throw this._formatSchemaError(e, fileName); } diff --git a/ext/bg/js/json-schema.js b/ext/bg/js/json-schema.js index 7cc87bb0..30446559 100644 --- a/ext/bg/js/json-schema.js +++ b/ext/bg/js/json-schema.js @@ -73,7 +73,7 @@ class JsonSchemaProxyHandler { } const value = target[property]; - return value !== null && typeof value === 'object' ? JsonSchema.createProxy(value, propertySchema) : value; + return value !== null && typeof value === 'object' ? this._jsonSchemaValidator.createProxy(value, propertySchema) : value; } set(target, property, value) { @@ -94,9 +94,9 @@ class JsonSchemaProxyHandler { throw new Error(`Property ${property} not supported`); } - value = JsonSchema.clone(value); + value = clone(value); - this._jsonSchemaValidator.validate(value, propertySchema, new JsonSchemaTraversalInfo(value, propertySchema)); + this._jsonSchemaValidator.validate(value, propertySchema); target[property] = value; return true; @@ -128,15 +128,70 @@ class JsonSchemaValidator { this._regexCache = new CacheMap(100, (pattern, flags) => new RegExp(pattern, flags)); } - getPropertySchema(schema, property, value, path=null) { - const type = this.getSchemaOrValueType(schema, value); + createProxy(target, schema) { + return new Proxy(target, new JsonSchemaProxyHandler(schema, this)); + } + + isValid(value, schema) { + try { + this.validate(value, schema); + return true; + } catch (e) { + return false; + } + } + + validate(value, schema) { + const info = new JsonSchemaTraversalInfo(value, schema); + this._validate(value, schema, info); + } + + getValidValueOrDefault(schema, value) { + let type = this._getValueType(value); + const schemaType = schema.type; + if (!this._isValueTypeAny(value, type, schemaType)) { + let assignDefault = true; + + const schemaDefault = schema.default; + if (typeof schemaDefault !== 'undefined') { + value = clone(schemaDefault); + type = this._getValueType(value); + assignDefault = !this._isValueTypeAny(value, type, schemaType); + } + + if (assignDefault) { + value = this._getDefaultTypeValue(schemaType); + type = this._getValueType(value); + } + } + + switch (type) { + case 'object': + value = this._populateObjectDefaults(value, schema); + break; + case 'array': + value = this._populateArrayDefaults(value, schema); + break; + } + + return value; + } + + getPropertySchema(schema, property, value) { + return this._getPropertySchema(schema, property, value, null); + } + + // Private + + _getPropertySchema(schema, property, value, path) { + const type = this._getSchemaOrValueType(schema, value); switch (type) { case 'object': { const properties = schema.properties; - if (this.isObject(properties)) { + if (this._isObject(properties)) { const propertySchema = properties[property]; - if (this.isObject(propertySchema)) { + if (this._isObject(propertySchema)) { if (path !== null) { path.push(['properties', properties], [property, propertySchema]); } return propertySchema; } @@ -145,7 +200,7 @@ class JsonSchemaValidator { const additionalProperties = schema.additionalProperties; if (additionalProperties === false) { return null; - } else if (this.isObject(additionalProperties)) { + } else if (this._isObject(additionalProperties)) { if (path !== null) { path.push(['additionalProperties', additionalProperties]); } return additionalProperties; } else { @@ -157,13 +212,13 @@ class JsonSchemaValidator { case 'array': { const items = schema.items; - if (this.isObject(items)) { + if (this._isObject(items)) { return items; } if (Array.isArray(items)) { if (property >= 0 && property < items.length) { const propertySchema = items[property]; - if (this.isObject(propertySchema)) { + if (this._isObject(propertySchema)) { if (path !== null) { path.push(['items', items], [property, propertySchema]); } return propertySchema; } @@ -173,7 +228,7 @@ class JsonSchemaValidator { const additionalItems = schema.additionalItems; if (additionalItems === false) { return null; - } else if (this.isObject(additionalItems)) { + } else if (this._isObject(additionalItems)) { if (path !== null) { path.push(['additionalItems', additionalItems]); } return additionalItems; } else { @@ -187,12 +242,12 @@ class JsonSchemaValidator { } } - getSchemaOrValueType(schema, value) { + _getSchemaOrValueType(schema, value) { const type = schema.type; if (Array.isArray(type)) { if (typeof value !== 'undefined') { - const valueType = this.getValueType(value); + const valueType = this._getValueType(value); if (type.indexOf(valueType) >= 0) { return valueType; } @@ -202,7 +257,7 @@ class JsonSchemaValidator { if (typeof type === 'undefined') { if (typeof value !== 'undefined') { - return this.getValueType(value); + return this._getValueType(value); } return null; } @@ -210,37 +265,37 @@ class JsonSchemaValidator { return type; } - validate(value, schema, info) { - this.validateSingleSchema(value, schema, info); - this.validateConditional(value, schema, info); - this.validateAllOf(value, schema, info); - this.validateAnyOf(value, schema, info); - this.validateOneOf(value, schema, info); - this.validateNoneOf(value, schema, info); + _validate(value, schema, info) { + this._validateSingleSchema(value, schema, info); + this._validateConditional(value, schema, info); + this._validateAllOf(value, schema, info); + this._validateAnyOf(value, schema, info); + this._validateOneOf(value, schema, info); + this._validateNoneOf(value, schema, info); } - validateConditional(value, schema, info) { + _validateConditional(value, schema, info) { const ifSchema = schema.if; - if (!this.isObject(ifSchema)) { return; } + if (!this._isObject(ifSchema)) { return; } let okay = true; info.schemaPush('if', ifSchema); try { - this.validate(value, ifSchema, info); + this._validate(value, ifSchema, info); } catch (e) { okay = false; } info.schemaPop(); const nextSchema = okay ? schema.then : schema.else; - if (this.isObject(nextSchema)) { + if (this._isObject(nextSchema)) { info.schemaPush(okay ? 'then' : 'else', nextSchema); - this.validate(value, nextSchema, info); + this._validate(value, nextSchema, info); info.schemaPop(); } } - validateAllOf(value, schema, info) { + _validateAllOf(value, schema, info) { const subSchemas = schema.allOf; if (!Array.isArray(subSchemas)) { return; } @@ -248,13 +303,13 @@ class JsonSchemaValidator { for (let i = 0; i < subSchemas.length; ++i) { const subSchema = subSchemas[i]; info.schemaPush(i, subSchema); - this.validate(value, subSchema, info); + this._validate(value, subSchema, info); info.schemaPop(); } info.schemaPop(); } - validateAnyOf(value, schema, info) { + _validateAnyOf(value, schema, info) { const subSchemas = schema.anyOf; if (!Array.isArray(subSchemas)) { return; } @@ -263,7 +318,7 @@ class JsonSchemaValidator { const subSchema = subSchemas[i]; info.schemaPush(i, subSchema); try { - this.validate(value, subSchema, info); + this._validate(value, subSchema, info); return; } catch (e) { // NOP @@ -275,7 +330,7 @@ class JsonSchemaValidator { // info.schemaPop(); // Unreachable } - validateOneOf(value, schema, info) { + _validateOneOf(value, schema, info) { const subSchemas = schema.oneOf; if (!Array.isArray(subSchemas)) { return; } @@ -285,7 +340,7 @@ class JsonSchemaValidator { const subSchema = subSchemas[i]; info.schemaPush(i, subSchema); try { - this.validate(value, subSchema, info); + this._validate(value, subSchema, info); ++count; } catch (e) { // NOP @@ -300,7 +355,7 @@ class JsonSchemaValidator { info.schemaPop(); } - validateNoneOf(value, schema, info) { + _validateNoneOf(value, schema, info) { const subSchemas = schema.not; if (!Array.isArray(subSchemas)) { return; } @@ -309,7 +364,7 @@ class JsonSchemaValidator { const subSchema = subSchemas[i]; info.schemaPush(i, subSchema); try { - this.validate(value, subSchema, info); + this._validate(value, subSchema, info); } catch (e) { info.schemaPop(); continue; @@ -319,40 +374,40 @@ class JsonSchemaValidator { info.schemaPop(); } - validateSingleSchema(value, schema, info) { - const type = this.getValueType(value); + _validateSingleSchema(value, schema, info) { + const type = this._getValueType(value); const schemaType = schema.type; - if (!this.isValueTypeAny(value, type, schemaType)) { + if (!this._isValueTypeAny(value, type, schemaType)) { throw new JsonSchemaValidationError(`Value type ${type} does not match schema type ${schemaType}`, value, schema, info); } const schemaConst = schema.const; - if (typeof schemaConst !== 'undefined' && !this.valuesAreEqual(value, schemaConst)) { + if (typeof schemaConst !== 'undefined' && !this._valuesAreEqual(value, schemaConst)) { throw new JsonSchemaValidationError('Invalid constant value', value, schema, info); } const schemaEnum = schema.enum; - if (Array.isArray(schemaEnum) && !this.valuesAreEqualAny(value, schemaEnum)) { + if (Array.isArray(schemaEnum) && !this._valuesAreEqualAny(value, schemaEnum)) { throw new JsonSchemaValidationError('Invalid enum value', value, schema, info); } switch (type) { case 'number': - this.validateNumber(value, schema, info); + this._validateNumber(value, schema, info); break; case 'string': - this.validateString(value, schema, info); + this._validateString(value, schema, info); break; case 'array': - this.validateArray(value, schema, info); + this._validateArray(value, schema, info); break; case 'object': - this.validateObject(value, schema, info); + this._validateObject(value, schema, info); break; } } - validateNumber(value, schema, info) { + _validateNumber(value, schema, info) { const multipleOf = schema.multipleOf; if (typeof multipleOf === 'number' && Math.floor(value / multipleOf) * multipleOf !== value) { throw new JsonSchemaValidationError(`Number is not a multiple of ${multipleOf}`, value, schema, info); @@ -379,7 +434,7 @@ class JsonSchemaValidator { } } - validateString(value, schema, info) { + _validateString(value, schema, info) { const minLength = schema.minLength; if (typeof minLength === 'number' && value.length < minLength) { throw new JsonSchemaValidationError('String length too short', value, schema, info); @@ -408,7 +463,7 @@ class JsonSchemaValidator { } } - validateArray(value, schema, info) { + _validateArray(value, schema, info) { const minItems = schema.minItems; if (typeof minItems === 'number' && value.length < minItems) { throw new JsonSchemaValidationError('Array length too short', value, schema, info); @@ -419,11 +474,11 @@ class JsonSchemaValidator { throw new JsonSchemaValidationError('Array length too long', value, schema, info); } - this.validateArrayContains(value, schema, info); + this._validateArrayContains(value, schema, info); for (let i = 0, ii = value.length; i < ii; ++i) { const schemaPath = []; - const propertySchema = this.getPropertySchema(schema, i, value, schemaPath); + const propertySchema = this._getPropertySchema(schema, i, value, schemaPath); if (propertySchema === null) { throw new JsonSchemaValidationError(`No schema found for array[${i}]`, value, schema, info); } @@ -432,22 +487,22 @@ class JsonSchemaValidator { for (const [p, s] of schemaPath) { info.schemaPush(p, s); } info.valuePush(i, propertyValue); - this.validate(propertyValue, propertySchema, info); + this._validate(propertyValue, propertySchema, info); info.valuePop(); for (let j = 0, jj = schemaPath.length; j < jj; ++j) { info.schemaPop(); } } } - validateArrayContains(value, schema, info) { + _validateArrayContains(value, schema, info) { const containsSchema = schema.contains; - if (!this.isObject(containsSchema)) { return; } + if (!this._isObject(containsSchema)) { return; } info.schemaPush('contains', containsSchema); for (let i = 0, ii = value.length; i < ii; ++i) { const propertyValue = value[i]; info.valuePush(i, propertyValue); try { - this.validate(propertyValue, containsSchema, info); + this._validate(propertyValue, containsSchema, info); info.schemaPop(); return; } catch (e) { @@ -458,7 +513,7 @@ class JsonSchemaValidator { throw new JsonSchemaValidationError('contains schema didn\'t match', value, schema, info); } - validateObject(value, schema, info) { + _validateObject(value, schema, info) { const properties = new Set(Object.getOwnPropertyNames(value)); const required = schema.required; @@ -482,7 +537,7 @@ class JsonSchemaValidator { for (const property of properties) { const schemaPath = []; - const propertySchema = this.getPropertySchema(schema, property, value, schemaPath); + const propertySchema = this._getPropertySchema(schema, property, value, schemaPath); if (propertySchema === null) { throw new JsonSchemaValidationError(`No schema found for ${property}`, value, schema, info); } @@ -491,18 +546,18 @@ class JsonSchemaValidator { for (const [p, s] of schemaPath) { info.schemaPush(p, s); } info.valuePush(property, propertyValue); - this.validate(propertyValue, propertySchema, info); + this._validate(propertyValue, propertySchema, info); info.valuePop(); for (let i = 0; i < schemaPath.length; ++i) { info.schemaPop(); } } } - isValueTypeAny(value, type, schemaTypes) { + _isValueTypeAny(value, type, schemaTypes) { if (typeof schemaTypes === 'string') { - return this.isValueType(value, type, schemaTypes); + return this._isValueType(value, type, schemaTypes); } else if (Array.isArray(schemaTypes)) { for (const schemaType of schemaTypes) { - if (this.isValueType(value, type, schemaType)) { + if (this._isValueType(value, type, schemaType)) { return true; } } @@ -511,14 +566,14 @@ class JsonSchemaValidator { return true; } - isValueType(value, type, schemaType) { + _isValueType(value, type, schemaType) { return ( type === schemaType || (schemaType === 'integer' && Math.floor(value) === value) ); } - getValueType(value) { + _getValueType(value) { const type = typeof value; if (type === 'object') { if (value === null) { return 'null'; } @@ -527,20 +582,20 @@ class JsonSchemaValidator { return type; } - valuesAreEqualAny(value1, valueList) { + _valuesAreEqualAny(value1, valueList) { for (const value2 of valueList) { - if (this.valuesAreEqual(value1, value2)) { + if (this._valuesAreEqual(value1, value2)) { return true; } } return false; } - valuesAreEqual(value1, value2) { + _valuesAreEqual(value1, value2) { return value1 === value2; } - getDefaultTypeValue(type) { + _getDefaultTypeValue(type) { if (typeof type === 'string') { switch (type) { case 'null': @@ -561,38 +616,7 @@ class JsonSchemaValidator { return null; } - getValidValueOrDefault(schema, value) { - let type = this.getValueType(value); - const schemaType = schema.type; - if (!this.isValueTypeAny(value, type, schemaType)) { - let assignDefault = true; - - const schemaDefault = schema.default; - if (typeof schemaDefault !== 'undefined') { - value = JsonSchema.clone(schemaDefault); - type = this.getValueType(value); - assignDefault = !this.isValueTypeAny(value, type, schemaType); - } - - if (assignDefault) { - value = this.getDefaultTypeValue(schemaType); - type = this.getValueType(value); - } - } - - switch (type) { - case 'object': - value = this.populateObjectDefaults(value, schema); - break; - case 'array': - value = this.populateArrayDefaults(value, schema); - break; - } - - return value; - } - - populateObjectDefaults(value, schema) { + _populateObjectDefaults(value, schema) { const properties = new Set(Object.getOwnPropertyNames(value)); const required = schema.required; @@ -600,14 +624,14 @@ class JsonSchemaValidator { for (const property of required) { properties.delete(property); - const propertySchema = this.getPropertySchema(schema, property, value); + const propertySchema = this._getPropertySchema(schema, property, value, null); if (propertySchema === null) { continue; } value[property] = this.getValidValueOrDefault(propertySchema, value[property]); } } for (const property of properties) { - const propertySchema = this.getPropertySchema(schema, property, value); + const propertySchema = this._getPropertySchema(schema, property, value, null); if (propertySchema === null) { Reflect.deleteProperty(value, property); } else { @@ -618,9 +642,9 @@ class JsonSchemaValidator { return value; } - populateArrayDefaults(value, schema) { + _populateArrayDefaults(value, schema) { for (let i = 0, ii = value.length; i < ii; ++i) { - const propertySchema = this.getPropertySchema(schema, i, value); + const propertySchema = this._getPropertySchema(schema, i, value, null); if (propertySchema === null) { continue; } value[i] = this.getValidValueOrDefault(propertySchema, value[i]); } @@ -628,7 +652,7 @@ class JsonSchemaValidator { return value; } - isObject(value) { + _isObject(value) { return typeof value === 'object' && value !== null && !Array.isArray(value); } @@ -679,22 +703,3 @@ class JsonSchemaValidationError extends Error { this.info = info; } } - -class JsonSchema { - static createProxy(target, schema) { - const validator = new JsonSchemaValidator(); - return new Proxy(target, new JsonSchemaProxyHandler(schema, validator)); - } - - static validate(value, schema) { - return new JsonSchemaValidator().validate(value, schema, new JsonSchemaTraversalInfo(value, schema)); - } - - static getValidValueOrDefault(schema, value) { - return new JsonSchemaValidator().getValidValueOrDefault(schema, value); - } - - static clone(value) { - return clone(value); - } -} diff --git a/test/dictionary-validate.js b/test/dictionary-validate.js index d01d74eb..a669a542 100644 --- a/test/dictionary-validate.js +++ b/test/dictionary-validate.js @@ -26,7 +26,7 @@ vm.execute([ 'mixed/js/cache-map.js', 'bg/js/json-schema.js' ]); -const JsonSchema = vm.get('JsonSchema'); +const JsonSchemaValidator = vm.get('JsonSchemaValidator'); function readSchema(relativeFileName) { @@ -45,7 +45,7 @@ async function validateDictionaryBanks(zip, fileNameFormat, schema) { if (!file) { break; } const data = JSON.parse(await file.async('string')); - JsonSchema.validate(data, schema); + new JsonSchemaValidator().validate(data, schema); ++index; } @@ -60,7 +60,7 @@ async function validateDictionary(archive, schemas) { const index = JSON.parse(await indexFile.async('string')); const version = index.format || index.version; - JsonSchema.validate(index, schemas.index); + new JsonSchemaValidator().validate(index, schemas.index); await validateDictionaryBanks(archive, 'term_bank_?.json', version === 1 ? schemas.termBankV1 : schemas.termBankV3); await validateDictionaryBanks(archive, 'term_meta_bank_?.json', schemas.termMetaBankV3); diff --git a/test/schema-validate.js b/test/schema-validate.js index aa9450dd..7b7a21a6 100644 --- a/test/schema-validate.js +++ b/test/schema-validate.js @@ -24,7 +24,7 @@ vm.execute([ 'mixed/js/cache-map.js', 'bg/js/json-schema.js' ]); -const JsonSchema = vm.get('JsonSchema'); +const JsonSchemaValidator = vm.get('JsonSchemaValidator'); function main() { @@ -45,7 +45,7 @@ function main() { console.log(`Validating ${dataFileName}...`); const dataSource = fs.readFileSync(dataFileName, {encoding: 'utf8'}); const data = JSON.parse(dataSource); - JsonSchema.validate(data, schema); + new JsonSchemaValidator().validate(data, schema); console.log('No issues found'); } catch (e) { console.warn(e); diff --git a/test/test-schema.js b/test/test-schema.js index b3544b28..a4a0de2e 100644 --- a/test/test-schema.js +++ b/test/test-schema.js @@ -24,7 +24,7 @@ vm.execute([ 'mixed/js/cache-map.js', 'bg/js/json-schema.js' ]); -const JsonSchema = vm.get('JsonSchema'); +const JsonSchemaValidator = vm.get('JsonSchemaValidator'); function testValidate1() { @@ -54,12 +54,7 @@ function testValidate1() { }; const schemaValidate = (value) => { - try { - JsonSchema.validate(value, schema); - return true; - } catch (e) { - return false; - } + return new JsonSchemaValidator().isValid(value, schema); }; const jsValidate = (value) => { @@ -395,12 +390,7 @@ function testValidate2() { ]; const schemaValidate = (value, schema) => { - try { - JsonSchema.validate(value, schema); - return true; - } catch (e) { - return false; - } + return new JsonSchemaValidator().isValid(value, schema); }; for (const {schema, inputs} of data) { @@ -555,7 +545,7 @@ function testGetValidValueOrDefault1() { for (const {schema, inputs} of data) { for (const [value, expected] of inputs) { - const actual = JsonSchema.getValidValueOrDefault(schema, value); + const actual = new JsonSchemaValidator().getValidValueOrDefault(schema, value); vm.assert.deepStrictEqual(actual, expected); } }