diff options
author | toasted-nutbread <toasted-nutbread@users.noreply.github.com> | 2020-02-08 12:28:06 -0500 |
---|---|---|
committer | GitHub <noreply@github.com> | 2020-02-08 12:28:06 -0500 |
commit | 4e2b317d549c8b3ff95190e0c81c64e7d5d12111 (patch) | |
tree | 5bb9c94cad2d047cf340b8c4e598f35fa66e9277 /ext | |
parent | 8eac191c97b755d7b090a46055b183750e6faa45 (diff) | |
parent | 3c28c7dd7cdbf4af91b0b4044f03e0877569e3b8 (diff) |
Merge pull request #345 from toasted-nutbread/json-schema-improvements
JSON schema improvements
Diffstat (limited to 'ext')
-rw-r--r-- | ext/bg/js/json-schema.js | 335 |
1 files changed, 277 insertions, 58 deletions
diff --git a/ext/bg/js/json-schema.js b/ext/bg/js/json-schema.js index 5d596a8b..3cf24c35 100644 --- a/ext/bg/js/json-schema.js +++ b/ext/bg/js/json-schema.js @@ -64,7 +64,7 @@ class JsonSchemaProxyHandler { } } - const propertySchema = JsonSchemaProxyHandler.getPropertySchema(this._schema, property); + const propertySchema = JsonSchemaProxyHandler.getPropertySchema(this._schema, property, target); if (propertySchema === null) { return; } @@ -86,17 +86,14 @@ class JsonSchemaProxyHandler { } } - const propertySchema = JsonSchemaProxyHandler.getPropertySchema(this._schema, property); + const propertySchema = JsonSchemaProxyHandler.getPropertySchema(this._schema, property, target); if (propertySchema === null) { throw new Error(`Property ${property} not supported`); } value = JsonSchema.isolate(value); - const error = JsonSchemaProxyHandler.validate(value, propertySchema); - if (error !== null) { - throw new Error(`Invalid value: ${error}`); - } + JsonSchemaProxyHandler.validate(value, propertySchema, new JsonSchemaTraversalInfo(value, propertySchema)); target[property] = value; return true; @@ -122,151 +119,329 @@ class JsonSchemaProxyHandler { throw new Error('construct not supported'); } - static getPropertySchema(schema, property) { - const type = schema.type; - if (Array.isArray(type)) { - throw new Error(`Ambiguous property type for ${property}`); - } + static getPropertySchema(schema, property, value, path=null) { + const type = JsonSchemaProxyHandler.getSchemaOrValueType(schema, value); switch (type) { case 'object': { const properties = schema.properties; - if (properties !== null && typeof properties === 'object' && !Array.isArray(properties)) { - if (Object.prototype.hasOwnProperty.call(properties, property)) { - return properties[property]; + if (JsonSchemaProxyHandler.isObject(properties)) { + const propertySchema = properties[property]; + if (JsonSchemaProxyHandler.isObject(propertySchema)) { + if (path !== null) { path.push(['properties', properties], [property, propertySchema]); } + return propertySchema; } } const additionalProperties = schema.additionalProperties; - return (additionalProperties !== null && typeof additionalProperties === 'object' && !Array.isArray(additionalProperties)) ? additionalProperties : null; + if (additionalProperties === false) { + return null; + } else if (JsonSchemaProxyHandler.isObject(additionalProperties)) { + if (path !== null) { path.push(['additionalProperties', additionalProperties]); } + return additionalProperties; + } else { + const result = JsonSchemaProxyHandler._unconstrainedSchema; + if (path !== null) { path.push([null, result]); } + return result; + } } case 'array': { const items = schema.items; - return (items !== null && typeof items === 'object' && !Array.isArray(items)) ? items : null; + if (JsonSchemaProxyHandler.isObject(items)) { + return items; + } + if (Array.isArray(items)) { + if (property >= 0 && property < items.length) { + const propertySchema = items[property]; + if (JsonSchemaProxyHandler.isObject(propertySchema)) { + if (path !== null) { path.push(['items', items], [property, propertySchema]); } + return propertySchema; + } + } + } + + const additionalItems = schema.additionalItems; + if (additionalItems === false) { + return null; + } else if (JsonSchemaProxyHandler.isObject(additionalItems)) { + if (path !== null) { path.push(['additionalItems', additionalItems]); } + return additionalItems; + } else { + const result = JsonSchemaProxyHandler._unconstrainedSchema; + if (path !== null) { path.push([null, result]); } + return result; + } } default: return null; } } - static validate(value, schema) { + static getSchemaOrValueType(schema, value) { + const type = schema.type; + + if (Array.isArray(type)) { + if (typeof value !== 'undefined') { + const valueType = JsonSchemaProxyHandler.getValueType(value); + if (type.indexOf(valueType) >= 0) { + return valueType; + } + } + return null; + } + + if (typeof type === 'undefined') { + if (typeof value !== 'undefined') { + return JsonSchemaProxyHandler.getValueType(value); + } + return null; + } + + return type; + } + + static validate(value, schema, info) { + JsonSchemaProxyHandler.validateSingleSchema(value, schema, info); + JsonSchemaProxyHandler.validateConditional(value, schema, info); + JsonSchemaProxyHandler.validateAllOf(value, schema, info); + JsonSchemaProxyHandler.validateAnyOf(value, schema, info); + JsonSchemaProxyHandler.validateOneOf(value, schema, info); + JsonSchemaProxyHandler.validateNoneOf(value, schema, info); + } + + static validateConditional(value, schema, info) { + const ifSchema = schema.if; + if (!JsonSchemaProxyHandler.isObject(ifSchema)) { return; } + + let okay = true; + info.schemaPush('if', ifSchema); + try { + JsonSchemaProxyHandler.validate(value, ifSchema, info); + } catch (e) { + okay = false; + } + info.schemaPop(); + + const nextSchema = okay ? schema.then : schema.else; + if (JsonSchemaProxyHandler.isObject(nextSchema)) { + info.schemaPush(okay ? 'then' : 'else', nextSchema); + JsonSchemaProxyHandler.validate(value, nextSchema, info); + info.schemaPop(); + } + } + + static validateAllOf(value, schema, info) { + const subSchemas = 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); + JsonSchemaProxyHandler.validate(value, subSchema, info); + info.schemaPop(); + } + info.schemaPop(); + } + + static validateAnyOf(value, schema, info) { + const subSchemas = 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 { + JsonSchemaProxyHandler.validate(value, subSchema, info); + return; + } catch (e) { + // NOP + } + info.schemaPop(); + } + + throw new JsonSchemaValidationError('0 anyOf schemas matched', value, schema, info); + // info.schemaPop(); // Unreachable + } + + static validateOneOf(value, schema, info) { + const subSchemas = 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 { + JsonSchemaProxyHandler.validate(value, subSchema, info); + ++count; + } catch (e) { + // NOP + } + info.schemaPop(); + } + + if (count !== 1) { + throw new JsonSchemaValidationError(`${count} oneOf schemas matched`, value, schema, info); + } + + info.schemaPop(); + } + + static validateNoneOf(value, schema, info) { + const subSchemas = 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 { + JsonSchemaProxyHandler.validate(value, subSchema, info); + } catch (e) { + info.schemaPop(); + continue; + } + throw new JsonSchemaValidationError(`not[${i}] schema matched`, value, schema, info); + } + info.schemaPop(); + } + + static validateSingleSchema(value, schema, info) { const type = JsonSchemaProxyHandler.getValueType(value); const schemaType = schema.type; if (!JsonSchemaProxyHandler.isValueTypeAny(value, type, schemaType)) { - return `Value type ${type} does not match schema type ${schemaType}`; + throw new JsonSchemaValidationError(`Value type ${type} does not match schema type ${schemaType}`, value, schema, info); } const schemaEnum = schema.enum; if (Array.isArray(schemaEnum) && !JsonSchemaProxyHandler.valuesAreEqualAny(value, schemaEnum)) { - return 'Invalid enum value'; + throw new JsonSchemaValidationError('Invalid enum value', value, schema, info); } switch (type) { case 'number': - return JsonSchemaProxyHandler.validateNumber(value, schema); + JsonSchemaProxyHandler.validateNumber(value, schema, info); + break; case 'string': - return JsonSchemaProxyHandler.validateString(value, schema); + JsonSchemaProxyHandler.validateString(value, schema, info); + break; case 'array': - return JsonSchemaProxyHandler.validateArray(value, schema); + JsonSchemaProxyHandler.validateArray(value, schema, info); + break; case 'object': - return JsonSchemaProxyHandler.validateObject(value, schema); - default: - return null; + JsonSchemaProxyHandler.validateObject(value, schema, info); + break; } } - static validateNumber(value, schema) { + static validateNumber(value, schema, info) { const multipleOf = schema.multipleOf; if (typeof multipleOf === 'number' && Math.floor(value / multipleOf) * multipleOf !== value) { - return `Number is not a multiple of ${multipleOf}`; + throw new JsonSchemaValidationError(`Number is not a multiple of ${multipleOf}`, value, schema, info); } const minimum = schema.minimum; if (typeof minimum === 'number' && value < minimum) { - return `Number is less than ${minimum}`; + throw new JsonSchemaValidationError(`Number is less than ${minimum}`, value, schema, info); } const exclusiveMinimum = schema.exclusiveMinimum; if (typeof exclusiveMinimum === 'number' && value <= exclusiveMinimum) { - return `Number is less than or equal to ${exclusiveMinimum}`; + throw new JsonSchemaValidationError(`Number is less than or equal to ${exclusiveMinimum}`, value, schema, info); } const maximum = schema.maximum; if (typeof maximum === 'number' && value > maximum) { - return `Number is greater than ${maximum}`; + throw new JsonSchemaValidationError(`Number is greater than ${maximum}`, value, schema, info); } const exclusiveMaximum = schema.exclusiveMaximum; if (typeof exclusiveMaximum === 'number' && value >= exclusiveMaximum) { - return `Number is greater than or equal to ${exclusiveMaximum}`; + throw new JsonSchemaValidationError(`Number is greater than or equal to ${exclusiveMaximum}`, value, schema, info); } - - return null; } - static validateString(value, schema) { + static validateString(value, schema, info) { const minLength = schema.minLength; if (typeof minLength === 'number' && value.length < minLength) { - return 'String length too short'; + throw new JsonSchemaValidationError('String length too short', value, schema, info); } - const maxLength = schema.minLength; + const maxLength = schema.maxLength; if (typeof maxLength === 'number' && value.length > maxLength) { - return 'String length too long'; + throw new JsonSchemaValidationError('String length too long', value, schema, info); } - - return null; } - static validateArray(value, schema) { + static validateArray(value, schema, info) { const minItems = schema.minItems; if (typeof minItems === 'number' && value.length < minItems) { - return 'Array length too short'; + throw new JsonSchemaValidationError('Array length too short', value, schema, info); } const maxItems = schema.maxItems; if (typeof maxItems === 'number' && value.length > maxItems) { - return 'Array length too long'; + throw new JsonSchemaValidationError('Array length too long', value, schema, info); } - return null; + for (let i = 0, ii = value.length; i < ii; ++i) { + const schemaPath = []; + const propertySchema = JsonSchemaProxyHandler.getPropertySchema(schema, i, value, schemaPath); + if (propertySchema === null) { + throw new JsonSchemaValidationError(`No schema found for array[${i}]`, value, schema, info); + } + + const propertyValue = value[i]; + + for (const [p, s] of schemaPath) { info.schemaPush(p, s); } + info.valuePush(i, propertyValue); + JsonSchemaProxyHandler.validate(propertyValue, propertySchema, info); + info.valuePop(); + for (let i = 0; i < schemaPath.length; ++i) { info.schemaPop(); } + } } - static validateObject(value, schema) { + static validateObject(value, schema, info) { const properties = new Set(Object.getOwnPropertyNames(value)); const required = schema.required; if (Array.isArray(required)) { for (const property of required) { if (!properties.has(property)) { - return `Missing property ${property}`; + throw new JsonSchemaValidationError(`Missing property ${property}`, value, schema, info); } } } const minProperties = schema.minProperties; if (typeof minProperties === 'number' && properties.length < minProperties) { - return 'Not enough object properties'; + throw new JsonSchemaValidationError('Not enough object properties', value, schema, info); } const maxProperties = schema.maxProperties; if (typeof maxProperties === 'number' && properties.length > maxProperties) { - return 'Too many object properties'; + throw new JsonSchemaValidationError('Too many object properties', value, schema, info); } for (const property of properties) { - const propertySchema = JsonSchemaProxyHandler.getPropertySchema(schema, property); + const schemaPath = []; + const propertySchema = JsonSchemaProxyHandler.getPropertySchema(schema, property, value, schemaPath); if (propertySchema === null) { - return `No schema found for ${property}`; - } - const error = JsonSchemaProxyHandler.validate(value[property], propertySchema); - if (error !== null) { - return error; + throw new JsonSchemaValidationError(`No schema found for ${property}`, value, schema, info); } - } - return null; + const propertyValue = value[property]; + + for (const [p, s] of schemaPath) { info.schemaPush(p, s); } + info.valuePush(property, propertyValue); + JsonSchemaProxyHandler.validate(propertyValue, propertySchema, info); + info.valuePop(); + for (let i = 0; i < schemaPath.length; ++i) { info.schemaPop(); } + } } static isValueTypeAny(value, type, schemaTypes) { @@ -372,14 +547,14 @@ class JsonSchemaProxyHandler { for (const property of required) { properties.delete(property); - const propertySchema = JsonSchemaProxyHandler.getPropertySchema(schema, property); + const propertySchema = JsonSchemaProxyHandler.getPropertySchema(schema, property, value); if (propertySchema === null) { continue; } value[property] = JsonSchemaProxyHandler.getValidValueOrDefault(propertySchema, value[property]); } } for (const property of properties) { - const propertySchema = JsonSchemaProxyHandler.getPropertySchema(schema, property); + const propertySchema = JsonSchemaProxyHandler.getPropertySchema(schema, property, value); if (propertySchema === null) { Reflect.deleteProperty(value, property); } else { @@ -392,13 +567,53 @@ class JsonSchemaProxyHandler { static populateArrayDefaults(value, schema) { for (let i = 0, ii = value.length; i < ii; ++i) { - const propertySchema = JsonSchemaProxyHandler.getPropertySchema(schema, i); + const propertySchema = JsonSchemaProxyHandler.getPropertySchema(schema, i, value); if (propertySchema === null) { continue; } value[i] = JsonSchemaProxyHandler.getValidValueOrDefault(propertySchema, value[i]); } return value; } + + static isObject(value) { + return typeof value === 'object' && value !== null && !Array.isArray(value); + } +} + +JsonSchemaProxyHandler._unconstrainedSchema = {}; + +class JsonSchemaTraversalInfo { + constructor(value, schema) { + this.valuePath = []; + this.schemaPath = []; + this.valuePush(null, value); + this.schemaPush(null, schema); + } + + valuePush(path, value) { + this.valuePath.push([path, value]); + } + + valuePop() { + this.valuePath.pop(); + } + + schemaPush(path, schema) { + this.schemaPath.push([path, schema]); + } + + schemaPop() { + this.schemaPath.pop(); + } +} + +class JsonSchemaValidationError extends Error { + constructor(message, value, schema, info) { + super(message); + this.value = value; + this.schema = schema; + this.info = info; + } } class JsonSchema { @@ -406,6 +621,10 @@ class JsonSchema { return new Proxy(target, new JsonSchemaProxyHandler(schema)); } + static validate(value, schema) { + return JsonSchemaProxyHandler.validate(value, schema, new JsonSchemaTraversalInfo(value, schema)); + } + static getValidValueOrDefault(schema, value) { return JsonSchemaProxyHandler.getValidValueOrDefault(schema, value); } |