From d16739a83a20e1729e08dbcbbc155be15972d146 Mon Sep 17 00:00:00 2001 From: toasted-nutbread Date: Sat, 22 May 2021 15:45:20 -0400 Subject: Json schema validation improvements (#1697) * Create new JsonSchema class * Add proxy handler * Update tests * Update validation scripts * Update backend * Update audio downloader * Update options util * Update dictionary importer * Update json schema file reference * Remove old json-schema.js * Rename new json-schema.js * Update file names * Rename class --- ext/js/data/json-schema.js | 967 ++++++++++++++++++++++++-------------------- ext/js/data/options-util.js | 14 +- 2 files changed, 532 insertions(+), 449 deletions(-) (limited to 'ext/js/data') diff --git a/ext/js/data/json-schema.js b/ext/js/data/json-schema.js index 6a590911..b5fac452 100644 --- a/ext/js/data/json-schema.js +++ b/ext/js/data/json-schema.js @@ -19,218 +19,202 @@ * CacheMap */ -class JsonSchemaProxyHandler { - constructor(schema, jsonSchemaValidator) { - this._schema = schema; - this._jsonSchemaValidator = jsonSchemaValidator; - } +class JsonSchema { + constructor(schema, rootSchema) { + this._schema = null; + this._startSchema = schema; + this._rootSchema = typeof rootSchema !== 'undefined' ? rootSchema : schema; + this._regexCache = null; + this._valuePath = []; + this._schemaPath = []; - getPrototypeOf(target) { - return Object.getPrototypeOf(target); + this._schemaPush(null, null); + this._valuePush(null, null); } - setPrototypeOf() { - throw new Error('setPrototypeOf not supported'); + get schema() { + return this._startSchema; } - isExtensible(target) { - return Object.isExtensible(target); + get rootSchema() { + return this._rootSchema; } - preventExtensions(target) { - Object.preventExtensions(target); - return true; + createProxy(value) { + return ( + typeof value === 'object' && value !== null ? + new Proxy(value, new JsonSchemaProxyHandler(this)) : + value + ); } - getOwnPropertyDescriptor(target, property) { - return Object.getOwnPropertyDescriptor(target, property); + isValid(value) { + try { + this.validate(value); + return true; + } catch (e) { + return false; + } } - defineProperty() { - throw new Error('defineProperty not supported'); + validate(value) { + this._schemaPush(null, this._startSchema); + this._valuePush(null, value); + try { + this._validate(value); + } finally { + this._valuePop(); + this._schemaPop(); + } } - has(target, property) { - return property in target; + getValidValueOrDefault(value) { + return this._getValidValueOrDefault(null, value, [{path: null, schema: this._startSchema}]); } - get(target, property) { - if (typeof property === 'symbol') { - return target[property]; - } - - if (Array.isArray(target)) { - if (typeof property === 'string' && /^\d+$/.test(property)) { - property = parseInt(property, 10); - } else if (typeof property === 'string') { - return target[property]; - } - } - - const propertySchema = this._jsonSchemaValidator.getPropertySchema(this._schema, property, target); - if (propertySchema === null) { - return; + getObjectPropertySchema(property) { + this._schemaPush(null, this._startSchema); + try { + const schemaPath = this._getObjectPropertySchemaPath(property); + return schemaPath !== null ? new JsonSchema(schemaPath[schemaPath.length - 1].schema, this._rootSchema) : null; + } finally { + this._schemaPop(); } - - const value = target[property]; - return value !== null && typeof value === 'object' ? this._jsonSchemaValidator.createProxy(value, propertySchema) : value; } - set(target, property, value) { - if (Array.isArray(target)) { - if (typeof property === 'string' && /^\d+$/.test(property)) { - property = parseInt(property, 10); - if (property > target.length) { - throw new Error('Array index out of range'); - } - } else if (typeof property === 'string') { - target[property] = value; - return true; - } - } - - const propertySchema = this._jsonSchemaValidator.getPropertySchema(this._schema, property, target); - if (propertySchema === null) { - throw new Error(`Property ${property} not supported`); + getArrayItemSchema(index) { + this._schemaPush(null, this._startSchema); + try { + const schemaPath = this._getArrayItemSchemaPath(index); + return schemaPath !== null ? new JsonSchema(schemaPath[schemaPath.length - 1].schema, this._rootSchema) : null; + } finally { + this._schemaPop(); } - - value = clone(value); - - this._jsonSchemaValidator.validate(value, propertySchema); - - target[property] = value; - return true; } - deleteProperty(target, property) { - const required = ( - (typeof target === 'object' && target !== null) ? - (Array.isArray(target) || this._jsonSchemaValidator.isObjectPropertyRequired(this._schema, property)) : - true - ); - if (required) { - throw new Error(`${property} cannot be deleted`); - } - return Reflect.deleteProperty(target, property); + isObjectPropertyRequired(property) { + const {required} = this._startSchema; + return Array.isArray(required) && required.includes(property); } - ownKeys(target) { - return Reflect.ownKeys(target); - } + // Stack - apply() { - throw new Error('apply not supported'); + _valuePush(path, value) { + this._valuePath.push({path, value}); } - construct() { - throw new Error('construct not supported'); + _valuePop() { + this._valuePath.pop(); } -} -class JsonSchemaValidator { - constructor() { - this._regexCache = new CacheMap(100); + _schemaPush(path, schema) { + this._schemaPath.push({path, schema}); + this._schema = schema; } - createProxy(target, schema) { - return new Proxy(target, new JsonSchemaProxyHandler(schema, this)); + _schemaPop() { + this._schemaPath.pop(); + this._schema = this._schemaPath[this._schemaPath.length - 1].schema; } - isValid(value, schema) { - try { - this.validate(value, schema); - return true; - } catch (e) { - return false; + // Private + + _createError(message) { + const valuePath = []; + for (let i = 1, ii = this._valuePath.length; i < ii; ++i) { + const {path, value} = this._valuePath[i]; + valuePath.push({path, value}); } - } - validate(value, schema) { - const info = new JsonSchemaTraversalInfo(value, schema); - this._validate(value, schema, info); - } + const schemaPath = []; + for (let i = 1, ii = this._schemaPath.length; i < ii; ++i) { + const {path, schema} = this._schemaPath[i]; + schemaPath.push({path, schema}); + } - getValidValueOrDefault(schema, value) { - const info = new JsonSchemaTraversalInfo(value, schema); - return this._getValidValueOrDefault(schema, value, info); + const error = new Error(message); + error.value = valuePath[valuePath.length - 1].value; + error.schema = this._schema; + error.valuePath = valuePath; + error.schemaPath = schemaPath; + return error; } - getPropertySchema(schema, property, value) { - return this._getPropertySchema(schema, property, value, null); + _isObject(value) { + return typeof value === 'object' && value !== null && !Array.isArray(value); } - clearCache() { - this._regexCache.clear(); - } + _getRegex(pattern, flags) { + if (this._regexCache === null) { + this._regexCache = new CacheMap(100); + } - isObjectPropertyRequired(schema, property) { - const {required} = schema; - return Array.isArray(required) && required.includes(property); + const key = `${flags}:${pattern}`; + let regex = this._regexCache.get(key); + if (typeof regex === 'undefined') { + regex = new RegExp(pattern, flags); + this._regexCache.set(key, regex); + } + return regex; } - // Private - - _getPropertySchema(schema, property, value, path) { - const type = this._getSchemaOrValueType(schema, value); - switch (type) { - case 'object': - { - const properties = schema.properties; - if (this._isObject(properties)) { - const propertySchema = properties[property]; - if (this._isObject(propertySchema)) { - if (path !== null) { path.push(['properties', properties], [property, propertySchema]); } - return propertySchema; - } - } + _getUnconstrainedSchema() { + return {}; + } - const additionalProperties = schema.additionalProperties; - if (additionalProperties === false) { - return null; - } else if (this._isObject(additionalProperties)) { - if (path !== null) { path.push(['additionalProperties', additionalProperties]); } - return additionalProperties; - } else { - const result = JsonSchemaValidator.unconstrainedSchema; - if (path !== null) { path.push([null, result]); } - return result; - } + _getObjectPropertySchemaPath(property) { + const {properties} = this._schema; + if (this._isObject(properties)) { + const propertySchema = properties[property]; + if (this._isObject(propertySchema)) { + return [ + {path: 'properties', schema: properties}, + {path: property, schema: propertySchema} + ]; } - case 'array': - { - const items = schema.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 (path !== null) { path.push(['items', items], [property, propertySchema]); } - return propertySchema; - } - } - } + } - const additionalItems = schema.additionalItems; - if (additionalItems === false) { - return null; - } else if (this._isObject(additionalItems)) { - if (path !== null) { path.push(['additionalItems', additionalItems]); } - return additionalItems; - } else { - const result = JsonSchemaValidator.unconstrainedSchema; - if (path !== null) { path.push([null, result]); } - return result; + const {additionalProperties} = this._schema; + if (additionalProperties === false) { + return null; + } else if (this._isObject(additionalProperties)) { + return [{path: 'additionalProperties', schema: additionalProperties}]; + } else { + const result = this._getUnconstrainedSchema(); + return [{path: null, schema: result}]; + } + } + + _getArrayItemSchemaPath(index) { + const {items} = this._schema; + if (this._isObject(items)) { + return [{path: 'items', schema: items}]; + } + if (Array.isArray(items)) { + if (index >= 0 && index < items.length) { + const propertySchema = items[index]; + if (this._isObject(propertySchema)) { + return [ + {path: 'items', schema: items}, + {path: index, schema: propertySchema} + ]; } } - default: - return null; + } + + const {additionalItems} = this._schema; + if (additionalItems === false) { + return null; + } else if (this._isObject(additionalItems)) { + return [{path: 'additionalItems', schema: additionalItems}]; + } else { + const result = this._getUnconstrainedSchema(); + return [{path: null, schema: result}]; } } - _getSchemaOrValueType(schema, value) { - const type = schema.type; + _getSchemaOrValueType(value) { + const {type} = this._schema; if (Array.isArray(type)) { if (typeof value !== 'undefined') { @@ -242,345 +226,391 @@ class JsonSchemaValidator { return null; } - if (typeof type === 'undefined') { - if (typeof value !== 'undefined') { - return this._getValueType(value); + if (typeof type !== 'undefined') { return type; } + return (typeof value !== 'undefined') ? this._getValueType(value) : null; + } + + _getValueType(value) { + const type = typeof value; + if (type === 'object') { + if (value === null) { return 'null'; } + if (Array.isArray(value)) { return 'array'; } + } + return type; + } + + _isValueTypeAny(value, type, schemaTypes) { + if (typeof schemaTypes === 'string') { + return this._isValueType(value, type, schemaTypes); + } else if (Array.isArray(schemaTypes)) { + for (const schemaType of schemaTypes) { + if (this._isValueType(value, type, schemaType)) { + return true; + } } - return null; + return false; } + return true; + } - return type; + _isValueType(value, type, schemaType) { + return ( + type === schemaType || + (schemaType === 'integer' && Math.floor(value) === value) + ); } - _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); + _valuesAreEqualAny(value1, valueList) { + for (const value2 of valueList) { + if (this._valuesAreEqual(value1, value2)) { + return true; + } + } + return false; + } + + _valuesAreEqual(value1, value2) { + return value1 === value2; + } + + // Validation + + _isValidCurrent(value) { + try { + this._validate(value); + return true; + } catch (e) { + return false; + } } - _validateConditional(value, schema, info) { - const ifSchema = schema.if; + _validate(value) { + this._validateSingleSchema(value); + this._validateConditional(value); + this._validateAllOf(value); + this._validateAnyOf(value); + this._validateOneOf(value); + this._validateNoneOf(value); + } + + _validateConditional(value) { + const ifSchema = this._schema.if; if (!this._isObject(ifSchema)) { return; } let okay = true; - info.schemaPush('if', ifSchema); + this._schemaPush('if', ifSchema); try { - this._validate(value, ifSchema, info); + this._validate(value); } catch (e) { okay = false; + } finally { + this._schemaPop(); } - info.schemaPop(); - const nextSchema = okay ? schema.then : schema.else; - if (this._isObject(nextSchema)) { - info.schemaPush(okay ? 'then' : 'else', nextSchema); - this._validate(value, nextSchema, info); - info.schemaPop(); + const nextSchema = okay ? this._schema.then : this._schema.else; + if (this._isObject(nextSchema)) { return; } + + this._schemaPush(okay ? 'then' : 'else', nextSchema); + try { + this._validate(value); + } finally { + this._schemaPop(); } } - _validateAllOf(value, schema, info) { - const subSchemas = schema.allOf; + _validateAllOf(value) { + const subSchemas = this._schema.allOf; if (!Array.isArray(subSchemas)) { return; } - info.schemaPush('allOf', subSchemas); - for (let i = 0; i < subSchemas.length; ++i) { - const subSchema = subSchemas[i]; - info.schemaPush(i, subSchema); - this._validate(value, subSchema, info); - info.schemaPop(); + this._schemaPush('allOf', subSchemas); + try { + for (let i = 0, ii = subSchemas.length; i < ii; ++i) { + const subSchema = subSchemas[i]; + if (!this._isObject(subSchema)) { continue; } + + this._schemaPush(i, subSchema); + try { + this._validate(value); + } finally { + this._schemaPop(); + } + } + } finally { + this._schemaPop(); } - info.schemaPop(); } - _validateAnyOf(value, schema, info) { - const subSchemas = schema.anyOf; + _validateAnyOf(value) { + const subSchemas = this._schema.anyOf; if (!Array.isArray(subSchemas)) { return; } - info.schemaPush('anyOf', subSchemas); - for (let i = 0; i < subSchemas.length; ++i) { - const subSchema = subSchemas[i]; - info.schemaPush(i, subSchema); - try { - this._validate(value, subSchema, info); - return; - } catch (e) { - // NOP + this._schemaPush('anyOf', subSchemas); + try { + for (let i = 0, ii = subSchemas.length; i < ii; ++i) { + const subSchema = subSchemas[i]; + if (!this._isObject(subSchema)) { continue; } + + this._schemaPush(i, subSchema); + try { + this._validate(value); + return; + } catch (e) { + // NOP + } finally { + this._schemaPop(); + } } - info.schemaPop(); - } - throw new JsonSchemaValidationError('0 anyOf schemas matched', value, schema, info); - // info.schemaPop(); // Unreachable + throw this._createError('0 anyOf schemas matched'); + } finally { + this._schemaPop(); + } } - _validateOneOf(value, schema, info) { - const subSchemas = schema.oneOf; + _validateOneOf(value) { + const subSchemas = this._schema.oneOf; if (!Array.isArray(subSchemas)) { return; } - info.schemaPush('oneOf', subSchemas); - let count = 0; - for (let i = 0; i < subSchemas.length; ++i) { - const subSchema = subSchemas[i]; - info.schemaPush(i, subSchema); - try { - this._validate(value, subSchema, info); - ++count; - } catch (e) { - // NOP + this._schemaPush('oneOf', subSchemas); + try { + let count = 0; + for (let i = 0, ii = subSchemas.length; i < ii; ++i) { + const subSchema = subSchemas[i]; + if (!this._isObject(subSchema)) { continue; } + + this._schemaPush(i, subSchema); + try { + this._validate(value); + ++count; + } catch (e) { + // NOP + } finally { + this._schemaPop(); + } } - info.schemaPop(); - } - if (count !== 1) { - throw new JsonSchemaValidationError(`${count} oneOf schemas matched`, value, schema, info); + if (count !== 1) { + throw this._createError(`${count} oneOf schemas matched`); + } + } finally { + this._schemaPop(); } - - info.schemaPop(); } - _validateNoneOf(value, schema, info) { - const subSchemas = schema.not; + _validateNoneOf(value) { + const subSchemas = this._schema.not; if (!Array.isArray(subSchemas)) { return; } - info.schemaPush('not', subSchemas); - for (let i = 0; i < subSchemas.length; ++i) { - const subSchema = subSchemas[i]; - info.schemaPush(i, subSchema); - try { - this._validate(value, subSchema, info); - } catch (e) { - info.schemaPop(); - continue; + this._schemaPush('not', subSchemas); + try { + for (let i = 0, ii = subSchemas.length; i < ii; ++i) { + const subSchema = subSchemas[i]; + if (!this._isObject(subSchema)) { continue; } + + this._schemaPush(i, subSchema); + try { + this._validate(value); + } catch (e) { + continue; + } finally { + this._schemaPop(); + } + throw this._createError(`not[${i}] schema matched`); } - throw new JsonSchemaValidationError(`not[${i}] schema matched`, value, schema, info); + } finally { + this._schemaPop(); } - info.schemaPop(); } - _validateSingleSchema(value, schema, info) { + _validateSingleSchema(value) { const type = this._getValueType(value); - const schemaType = schema.type; + const schemaType = this._schema.type; if (!this._isValueTypeAny(value, type, schemaType)) { - throw new JsonSchemaValidationError(`Value type ${type} does not match schema type ${schemaType}`, value, schema, info); + throw this._createError(`Value type ${type} does not match schema type ${schemaType}`); } - const schemaConst = schema.const; + const schemaConst = this._schema.const; if (typeof schemaConst !== 'undefined' && !this._valuesAreEqual(value, schemaConst)) { - throw new JsonSchemaValidationError('Invalid constant value', value, schema, info); + throw this._createError('Invalid constant value'); } - const schemaEnum = schema.enum; + const schemaEnum = this._schema.enum; if (Array.isArray(schemaEnum) && !this._valuesAreEqualAny(value, schemaEnum)) { - throw new JsonSchemaValidationError('Invalid enum value', value, schema, info); + throw this._createError('Invalid enum value'); } switch (type) { case 'number': - this._validateNumber(value, schema, info); + this._validateNumber(value); break; case 'string': - this._validateString(value, schema, info); + this._validateString(value); break; case 'array': - this._validateArray(value, schema, info); + this._validateArray(value); break; case 'object': - this._validateObject(value, schema, info); + this._validateObject(value); break; } } - _validateNumber(value, schema, info) { - const multipleOf = schema.multipleOf; + _validateNumber(value) { + const {multipleOf} = this._schema; if (typeof multipleOf === 'number' && Math.floor(value / multipleOf) * multipleOf !== value) { - throw new JsonSchemaValidationError(`Number is not a multiple of ${multipleOf}`, value, schema, info); + throw this._createError(`Number is not a multiple of ${multipleOf}`); } - const minimum = schema.minimum; + const {minimum} = this._schema; if (typeof minimum === 'number' && value < minimum) { - throw new JsonSchemaValidationError(`Number is less than ${minimum}`, value, schema, info); + throw this._createError(`Number is less than ${minimum}`); } - const exclusiveMinimum = schema.exclusiveMinimum; + const {exclusiveMinimum} = this._schema; if (typeof exclusiveMinimum === 'number' && value <= exclusiveMinimum) { - throw new JsonSchemaValidationError(`Number is less than or equal to ${exclusiveMinimum}`, value, schema, info); + throw this._createError(`Number is less than or equal to ${exclusiveMinimum}`); } - const maximum = schema.maximum; + const {maximum} = this._schema; if (typeof maximum === 'number' && value > maximum) { - throw new JsonSchemaValidationError(`Number is greater than ${maximum}`, value, schema, info); + throw this._createError(`Number is greater than ${maximum}`); } - const exclusiveMaximum = schema.exclusiveMaximum; + const {exclusiveMaximum} = this._schema; if (typeof exclusiveMaximum === 'number' && value >= exclusiveMaximum) { - throw new JsonSchemaValidationError(`Number is greater than or equal to ${exclusiveMaximum}`, value, schema, info); + throw this._createError(`Number is greater than or equal to ${exclusiveMaximum}`); } } - _validateString(value, schema, info) { - const minLength = schema.minLength; + _validateString(value) { + const {minLength} = this._schema; if (typeof minLength === 'number' && value.length < minLength) { - throw new JsonSchemaValidationError('String length too short', value, schema, info); + throw this._createError('String length too short'); } - const maxLength = schema.maxLength; + const {maxLength} = this._schema; if (typeof maxLength === 'number' && value.length > maxLength) { - throw new JsonSchemaValidationError('String length too long', value, schema, info); + throw this._createError('String length too long'); } - const pattern = schema.pattern; + const {pattern} = this._schema; if (typeof pattern === 'string') { - let patternFlags = schema.patternFlags; + let {patternFlags} = this._schema; if (typeof patternFlags !== 'string') { patternFlags = ''; } let regex; try { regex = this._getRegex(pattern, patternFlags); } catch (e) { - throw new JsonSchemaValidationError(`Pattern is invalid (${e.message})`, value, schema, info); + throw this._createError(`Pattern is invalid (${e.message})`); } if (!regex.test(value)) { - throw new JsonSchemaValidationError('Pattern match failed', value, schema, info); + throw this._createError('Pattern match failed'); } } } - _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); + _validateArray(value) { + const {length} = value; + + const {minItems} = this._schema; + if (typeof minItems === 'number' && length < minItems) { + throw this._createError('Array length too short'); } - const maxItems = schema.maxItems; - if (typeof maxItems === 'number' && value.length > maxItems) { - throw new JsonSchemaValidationError('Array length too long', value, schema, info); + const {maxItems} = this._schema; + if (typeof maxItems === 'number' && length > maxItems) { + throw this._createError('Array length too long'); } - this._validateArrayContains(value, schema, info); + this._validateArrayContains(value); - for (let i = 0, ii = value.length; i < ii; ++i) { - const schemaPath = []; - const propertySchema = this._getPropertySchema(schema, i, value, schemaPath); - if (propertySchema === null) { - throw new JsonSchemaValidationError(`No schema found for array[${i}]`, value, schema, info); + for (let i = 0; i < length; ++i) { + const schemaPath = this._getArrayItemSchemaPath(i); + if (schemaPath === null) { + throw this._createError(`No schema found for array[${i}]`); } const propertyValue = value[i]; - for (const [p, s] of schemaPath) { info.schemaPush(p, s); } - info.valuePush(i, propertyValue); - this._validate(propertyValue, propertySchema, info); - info.valuePop(); - for (let j = 0, jj = schemaPath.length; j < jj; ++j) { info.schemaPop(); } + for (const {path, schema} of schemaPath) { this._schemaPush(path, schema); } + this._valuePush(i, propertyValue); + try { + this._validate(propertyValue); + } finally { + this._valuePop(); + for (let j = 0, jj = schemaPath.length; j < jj; ++j) { this._schemaPop(); } + } } } - _validateArrayContains(value, schema, info) { - const containsSchema = schema.contains; + _validateArrayContains(value) { + const containsSchema = this._schema.contains; 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); - info.schemaPop(); - return; - } catch (e) { - // NOP + this._schemaPush('contains', containsSchema); + try { + for (let i = 0, ii = value.length; i < ii; ++i) { + const propertyValue = value[i]; + this._valuePush(i, propertyValue); + try { + this._validate(propertyValue); + return; + } catch (e) { + // NOP + } finally { + this._valuePop(); + } } - info.valuePop(); + throw this._createError('contains schema didn\'t match'); + } finally { + this._schemaPop(); } - throw new JsonSchemaValidationError('contains schema didn\'t match', value, schema, info); } - _validateObject(value, schema, info) { + _validateObject(value) { const properties = new Set(Object.getOwnPropertyNames(value)); - const required = schema.required; + const {required} = this._schema; if (Array.isArray(required)) { for (const property of required) { if (!properties.has(property)) { - throw new JsonSchemaValidationError(`Missing property ${property}`, value, schema, info); + throw this._createError(`Missing property ${property}`); } } } - const minProperties = schema.minProperties; + const {minProperties} = this._schema; if (typeof minProperties === 'number' && properties.length < minProperties) { - throw new JsonSchemaValidationError('Not enough object properties', value, schema, info); + throw this._createError('Not enough object properties'); } - const maxProperties = schema.maxProperties; + const {maxProperties} = this._schema; if (typeof maxProperties === 'number' && properties.length > maxProperties) { - throw new JsonSchemaValidationError('Too many object properties', value, schema, info); + throw this._createError('Too many object properties'); } for (const property of properties) { - const schemaPath = []; - const propertySchema = this._getPropertySchema(schema, property, value, schemaPath); - if (propertySchema === null) { - throw new JsonSchemaValidationError(`No schema found for ${property}`, value, schema, info); + const schemaPath = this._getObjectPropertySchemaPath(property); + if (schemaPath === null) { + throw this._createError(`No schema found for ${property}`); } const propertyValue = value[property]; - for (const [p, s] of schemaPath) { info.schemaPush(p, s); } - info.valuePush(property, propertyValue); - this._validate(propertyValue, propertySchema, info); - info.valuePop(); - for (let i = 0; i < schemaPath.length; ++i) { info.schemaPop(); } - } - } - - _isValueTypeAny(value, type, schemaTypes) { - if (typeof schemaTypes === 'string') { - return this._isValueType(value, type, schemaTypes); - } else if (Array.isArray(schemaTypes)) { - for (const schemaType of schemaTypes) { - if (this._isValueType(value, type, schemaType)) { - return true; - } - } - return false; - } - return true; - } - - _isValueType(value, type, schemaType) { - return ( - type === schemaType || - (schemaType === 'integer' && Math.floor(value) === value) - ); - } - - _getValueType(value) { - const type = typeof value; - if (type === 'object') { - if (value === null) { return 'null'; } - if (Array.isArray(value)) { return 'array'; } - } - return type; - } - - _valuesAreEqualAny(value1, valueList) { - for (const value2 of valueList) { - if (this._valuesAreEqual(value1, value2)) { - return true; + for (const {path, schema} of schemaPath) { this._schemaPush(path, schema); } + this._valuePush(property, propertyValue); + try { + this._validate(propertyValue); + } finally { + this._valuePop(); + for (let j = 0, jj = schemaPath.length; j < jj; ++j) { this._schemaPop(); } } } - return false; } - _valuesAreEqual(value1, value2) { - return value1 === value2; - } + // Creation _getDefaultTypeValue(type) { if (typeof type === 'string') { @@ -603,9 +633,8 @@ class JsonSchemaValidator { return null; } - _getDefaultSchemaValue(schema) { - const schemaType = schema.type; - const schemaDefault = schema.default; + _getDefaultSchemaValue() { + const {type: schemaType, default: schemaDefault} = this._schema; return ( typeof schemaDefault !== 'undefined' && this._isValueTypeAny(schemaDefault, this._getValueType(schemaDefault), schemaType) ? @@ -614,24 +643,35 @@ class JsonSchemaValidator { ); } - _getValidValueOrDefault(schema, value, info) { + _getValidValueOrDefault(path, value, schemaPath) { + this._valuePush(path, value); + for (const {path: path2, schema} of schemaPath) { this._schemaPush(path2, schema); } + try { + return this._getValidValueOrDefaultInner(value); + } finally { + for (let i = 0, ii = schemaPath.length; i < ii; ++i) { this._schemaPop(); } + this._valuePop(); + } + } + + _getValidValueOrDefaultInner(value) { let type = this._getValueType(value); - if (typeof value === 'undefined' || !this._isValueTypeAny(value, type, schema.type)) { - value = this._getDefaultSchemaValue(schema); + if (typeof value === 'undefined' || !this._isValueTypeAny(value, type, this._schema.type)) { + value = this._getDefaultSchemaValue(); type = this._getValueType(value); } switch (type) { case 'object': - value = this._populateObjectDefaults(value, schema, info); + value = this._populateObjectDefaults(value); break; case 'array': - value = this._populateArrayDefaults(value, schema, info); + value = this._populateArrayDefaults(value); break; default: - if (!this.isValid(value, schema)) { - const schemaDefault = this._getDefaultSchemaValue(schema); - if (this.isValid(schemaDefault, schema)) { + if (!this._isValidCurrent(value)) { + const schemaDefault = this._getDefaultSchemaValue(); + if (this._isValidCurrent(schemaDefault)) { value = schemaDefault; } } @@ -641,126 +681,169 @@ class JsonSchemaValidator { return value; } - _populateObjectDefaults(value, schema, info) { + _populateObjectDefaults(value) { const properties = new Set(Object.getOwnPropertyNames(value)); - const required = schema.required; + const {required} = this._schema; if (Array.isArray(required)) { for (const property of required) { properties.delete(property); - - const propertySchema = this._getPropertySchema(schema, property, value, null); - if (propertySchema === null) { continue; } - info.valuePush(property, value); - info.schemaPush(property, propertySchema); - const hasValue = Object.prototype.hasOwnProperty.call(value, property); - value[property] = this._getValidValueOrDefault(propertySchema, hasValue ? value[property] : void 0, info); - info.schemaPop(); - info.valuePop(); + const schemaPath = this._getObjectPropertySchemaPath(property); + if (schemaPath === null) { continue; } + const propertyValue = Object.prototype.hasOwnProperty.call(value, property) ? value[property] : void 0; + value[property] = this._getValidValueOrDefault(property, propertyValue, schemaPath); } } for (const property of properties) { - const propertySchema = this._getPropertySchema(schema, property, value, null); - if (propertySchema === null) { + const schemaPath = this._getObjectPropertySchemaPath(property); + if (schemaPath === null) { Reflect.deleteProperty(value, property); } else { - info.valuePush(property, value); - info.schemaPush(property, propertySchema); - value[property] = this._getValidValueOrDefault(propertySchema, value[property], info); - info.schemaPop(); - info.valuePop(); + value[property] = this._getValidValueOrDefault(property, value[property], schemaPath); } } return value; } - _populateArrayDefaults(value, schema, info) { + _populateArrayDefaults(value) { for (let i = 0, ii = value.length; i < ii; ++i) { - const propertySchema = this._getPropertySchema(schema, i, value, null); - if (propertySchema === null) { continue; } - info.valuePush(i, value); - info.schemaPush(i, propertySchema); - value[i] = this._getValidValueOrDefault(propertySchema, value[i], info); - info.schemaPop(); - info.valuePop(); + const schemaPath = this._getArrayItemSchemaPath(i); + if (schemaPath === null) { continue; } + const propertyValue = value[i]; + value[i] = this._getValidValueOrDefault(i, propertyValue, schemaPath); } - const minItems = schema.minItems; + const {minItems, maxItems} = this._schema; if (typeof minItems === 'number' && value.length < minItems) { for (let i = value.length; i < minItems; ++i) { - const propertySchema = this._getPropertySchema(schema, i, value, null); - if (propertySchema === null) { break; } - info.valuePush(i, value); - info.schemaPush(i, propertySchema); - const item = this._getValidValueOrDefault(propertySchema, void 0, info); - info.schemaPop(); - info.valuePop(); + const schemaPath = this._getArrayItemSchemaPath(i); + if (schemaPath === null) { break; } + const item = this._getValidValueOrDefault(i, void 0, schemaPath); value.push(item); } } - const maxItems = schema.maxItems; if (typeof maxItems === 'number' && value.length > maxItems) { value.splice(maxItems, value.length - maxItems); } return value; } +} - _isObject(value) { - return typeof value === 'object' && value !== null && !Array.isArray(value); +class JsonSchemaProxyHandler { + constructor(schema) { + this._schema = schema; + this._numberPattern = /^(?:0|[1-9]\d*)$/; } - _getRegex(pattern, flags) { - const key = `${flags}:${pattern}`; - let regex = this._regexCache.get(key); - if (typeof regex === 'undefined') { - regex = new RegExp(pattern, flags); - this._regexCache.set(key, regex); + getPrototypeOf(target) { + return Object.getPrototypeOf(target); + } + + setPrototypeOf() { + throw new Error('setPrototypeOf not supported'); + } + + isExtensible(target) { + return Object.isExtensible(target); + } + + preventExtensions(target) { + Object.preventExtensions(target); + return true; + } + + getOwnPropertyDescriptor(target, property) { + return Object.getOwnPropertyDescriptor(target, property); + } + + defineProperty() { + throw new Error('defineProperty not supported'); + } + + has(target, property) { + return property in target; + } + + get(target, property) { + if (typeof property === 'symbol') { return target[property]; } + + let propertySchema; + if (Array.isArray(target)) { + property = this._getArrayIndex(property); + if (property === null) { + // Note: this does not currently wrap mutating functions like push, pop, shift, unshift, splice + return target[property]; + } + propertySchema = this._schema.getArrayItemSchema(property); + } else { + propertySchema = this._schema.getObjectPropertySchema(property); } - return regex; + + if (propertySchema === null) { return void 0; } + + const value = target[property]; + return value !== null && typeof value === 'object' ? propertySchema.createProxy(value) : value; } -} -Object.defineProperty(JsonSchemaValidator, 'unconstrainedSchema', { - value: Object.freeze({}), - configurable: false, - enumerable: true, - writable: false -}); + set(target, property, value) { + if (typeof property === 'symbol') { throw new Error(`Cannot assign symbol property ${property}`); } + + let propertySchema; + if (Array.isArray(target)) { + property = this._getArrayIndex(property); + if (property === null) { throw new Error(`Property ${property} cannot be assigned to array`); } + if (property > target.length) { throw new Error('Array index out of range'); } + propertySchema = this._schema.getArrayItemSchema(property); + } else { + propertySchema = this._schema.getObjectPropertySchema(property); + } + + if (propertySchema === null) { throw new Error(`Property ${property} not supported`); } + + value = clone(value); + propertySchema.validate(value); -class JsonSchemaTraversalInfo { - constructor(value, schema) { - this.valuePath = []; - this.schemaPath = []; - this.valuePush(null, value); - this.schemaPush(null, schema); + target[property] = value; + return true; } - valuePush(path, value) { - this.valuePath.push([path, value]); + deleteProperty(target, property) { + const required = ( + (typeof target === 'object' && target !== null) ? + (Array.isArray(target) || this._schema.isObjectPropertyRequired(property)) : + true + ); + if (required) { + throw new Error(`${property} cannot be deleted`); + } + return Reflect.deleteProperty(target, property); } - valuePop() { - this.valuePath.pop(); + ownKeys(target) { + return Reflect.ownKeys(target); } - schemaPush(path, schema) { - this.schemaPath.push([path, schema]); + apply() { + throw new Error('apply not supported'); } - schemaPop() { - this.schemaPath.pop(); + construct() { + throw new Error('construct not supported'); } -} -class JsonSchemaValidationError extends Error { - constructor(message, value, schema, info) { - super(message); - this.value = value; - this.schema = schema; - this.info = info; + // Private + + _getArrayIndex(property) { + if (typeof property === 'string' && this._numberPattern.test(property)) { + return Number.parseInt(property, 10); + } else if (typeof property === 'number' && Math.floor(property) === property && property >= 0) { + return property; + } else { + return null; + } } } diff --git a/ext/js/data/options-util.js b/ext/js/data/options-util.js index 00ad890d..740afa76 100644 --- a/ext/js/data/options-util.js +++ b/ext/js/data/options-util.js @@ -16,19 +16,19 @@ */ /* global - * JsonSchemaValidator + * JsonSchema * TemplatePatcher */ class OptionsUtil { constructor() { - this._schemaValidator = new JsonSchemaValidator(); this._templatePatcher = null; this._optionsSchema = null; } async prepare() { - this._optionsSchema = await this._fetchAsset('/data/schemas/options-schema.json', true); + const schema = await this._fetchAsset('/data/schemas/options-schema.json', true); + this._optionsSchema = new JsonSchema(schema); } async update(options) { @@ -87,7 +87,7 @@ class OptionsUtil { options = await this._applyUpdates(options, this._getVersionUpdates()); // Validation - options = this._schemaValidator.getValidValueOrDefault(this._optionsSchema, options); + options = this._optionsSchema.getValidValueOrDefault(options); // Result return options; @@ -135,17 +135,17 @@ class OptionsUtil { getDefault() { const optionsVersion = this._getVersionUpdates().length; - const options = this._schemaValidator.getValidValueOrDefault(this._optionsSchema); + const options = this._optionsSchema.getValidValueOrDefault(); options.version = optionsVersion; return options; } createValidatingProxy(options) { - return this._schemaValidator.createProxy(options, this._optionsSchema); + return this._optionsSchema.createProxy(options); } validate(options) { - return this._schemaValidator.validate(options, this._optionsSchema); + return this._optionsSchema.validate(options); } // Legacy profile updating -- cgit v1.2.3