From b770944b127a8b549b94f6ba2b038917acd63eff Mon Sep 17 00:00:00 2001 From: toasted-nutbread Date: Thu, 28 Nov 2019 12:19:15 -0500 Subject: [PATCH] Create proxy system for json schema validation --- ext/bg/js/json-schema.js | 413 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 413 insertions(+) create mode 100644 ext/bg/js/json-schema.js diff --git a/ext/bg/js/json-schema.js b/ext/bg/js/json-schema.js new file mode 100644 index 00000000..b059d757 --- /dev/null +++ b/ext/bg/js/json-schema.js @@ -0,0 +1,413 @@ +/* + * Copyright (C) 2019 Alex Yatskov + * Author: Alex Yatskov + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + + +class JsonSchemaProxyHandler { + constructor(schema) { + this._schema = schema; + } + + 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]; + } + + 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 = JsonSchemaProxyHandler.getPropertySchema(this._schema, property); + if (propertySchema === null) { + return; + } + + const value = target[property]; + return value !== null && typeof value === 'object' ? JsonSchema.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 = JsonSchemaProxyHandler.getPropertySchema(this._schema, property); + 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}`); + } + + target[property] = value; + return true; + } + + deleteProperty(target, property) { + const required = this._schema.required; + if (Array.isArray(required) && required.includes(property)) { + throw new Error(`${property} cannot be deleted`); + } + return Reflect.deleteProperty(target, property); + } + + ownKeys(target) { + return Reflect.ownKeys(target); + } + + apply() { + throw new Error('apply not supported'); + } + + construct() { + 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}`); + } + 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]; + } + } + + const additionalProperties = schema.additionalProperties; + return (additionalProperties !== null && typeof additionalProperties === 'object' && !Array.isArray(additionalProperties)) ? additionalProperties : null; + } + case 'array': + { + const items = schema.items; + return (items !== null && typeof items === 'object' && !Array.isArray(items)) ? items : null; + } + default: + return null; + } + } + + static validate(value, schema) { + 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}`; + } + + const schemaEnum = schema.enum; + if (Array.isArray(schemaEnum) && !JsonSchemaProxyHandler.valuesAreEqualAny(value, schemaEnum)) { + return 'Invalid enum value'; + } + + switch (type) { + case 'number': + return JsonSchemaProxyHandler.validateNumber(value, schema); + case 'string': + return JsonSchemaProxyHandler.validateString(value, schema); + case 'array': + return JsonSchemaProxyHandler.validateArray(value, schema); + case 'object': + return JsonSchemaProxyHandler.validateObject(value, schema); + default: + return null; + } + } + + static validateNumber(value, schema) { + const multipleOf = schema.multipleOf; + if (typeof multipleOf === 'number' && Math.floor(value / multipleOf) * multipleOf !== value) { + return `Number is not a multiple of ${multipleOf}`; + } + + const minimum = schema.minimum; + if (typeof minimum === 'number' && value < minimum) { + return `Number is less than ${minimum}`; + } + + const exclusiveMinimum = schema.exclusiveMinimum; + if (typeof exclusiveMinimum === 'number' && value <= exclusiveMinimum) { + return `Number is less than or equal to ${exclusiveMinimum}`; + } + + const maximum = schema.maximum; + if (typeof maximum === 'number' && value > maximum) { + return `Number is greater than ${maximum}`; + } + + const exclusiveMaximum = schema.exclusiveMaximum; + if (typeof exclusiveMaximum === 'number' && value >= exclusiveMaximum) { + return `Number is greater than or equal to ${exclusiveMaximum}`; + } + + return null; + } + + static validateString(value, schema) { + const minLength = schema.minLength; + if (typeof minLength === 'number' && value.length < minLength) { + return 'String length too short'; + } + + const maxLength = schema.minLength; + if (typeof maxLength === 'number' && value.length > maxLength) { + return 'String length too long'; + } + + return null; + } + + static validateArray(value, schema) { + const minItems = schema.minItems; + if (typeof minItems === 'number' && value.length < minItems) { + return 'Array length too short'; + } + + const maxItems = schema.maxItems; + if (typeof maxItems === 'number' && value.length > maxItems) { + return 'Array length too long'; + } + + return null; + } + + static validateObject(value, schema) { + 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}`; + } + } + } + + const minProperties = schema.minProperties; + if (typeof minProperties === 'number' && properties.length < minProperties) { + return 'Not enough object properties'; + } + + const maxProperties = schema.maxProperties; + if (typeof maxProperties === 'number' && properties.length > maxProperties) { + return 'Too many object properties'; + } + + for (const property of properties) { + const propertySchema = JsonSchemaProxyHandler.getPropertySchema(schema, property); + if (propertySchema === null) { + return `No schema found for ${property}`; + } + const error = JsonSchemaProxyHandler.validate(value[property], propertySchema); + if (error !== null) { + return error; + } + } + + return null; + } + + static isValueTypeAny(value, type, schemaTypes) { + if (typeof schemaTypes === 'string') { + return JsonSchemaProxyHandler.isValueType(value, type, schemaTypes); + } else if (Array.isArray(schemaTypes)) { + for (const schemaType of schemaTypes) { + if (JsonSchemaProxyHandler.isValueType(value, type, schemaType)) { + return true; + } + } + return false; + } + return true; + } + + static isValueType(value, type, schemaType) { + return ( + type === schemaType || + (schemaType === 'integer' && Math.floor(value) === value) + ); + } + + static getValueType(value) { + const type = typeof value; + if (type === 'object') { + if (value === null) { return 'null'; } + if (Array.isArray(value)) { return 'array'; } + } + return type; + } + + static valuesAreEqualAny(value1, valueList) { + for (const value2 of valueList) { + if (JsonSchemaProxyHandler.valuesAreEqual(value1, value2)) { + return true; + } + } + return false; + } + + static valuesAreEqual(value1, value2) { + return value1 === value2; + } + + static getDefaultTypeValue(type) { + if (typeof type === 'string') { + switch (type) { + case 'null': + return null; + case 'boolean': + return false; + case 'number': + case 'integer': + return 0; + case 'string': + return ''; + case 'array': + return []; + case 'object': + return {}; + } + } + return null; + } + + static getValidValueOrDefault(schema, value) { + let type = JsonSchemaProxyHandler.getValueType(value); + const schemaType = schema.type; + if (!JsonSchemaProxyHandler.isValueTypeAny(value, type, schemaType)) { + let assignDefault = true; + + const schemaDefault = schema.default; + if (typeof schemaDefault !== 'undefined') { + value = JsonSchema.isolate(schemaDefault); + type = JsonSchemaProxyHandler.getValueType(value); + assignDefault = !JsonSchemaProxyHandler.isValueTypeAny(value, type, schemaType); + } + + if (assignDefault) { + value = JsonSchemaProxyHandler.getDefaultTypeValue(schemaType); + type = JsonSchemaProxyHandler.getValueType(value); + } + } + + if (type === 'object') { + value = JsonSchemaProxyHandler.populateObjectDefaults(value, schema); + } + + return value; + } + + static populateObjectDefaults(value, schema) { + const properties = new Set(Object.getOwnPropertyNames(value)); + + const required = schema.required; + if (Array.isArray(required)) { + for (const property of required) { + properties.delete(property); + + const propertySchema = JsonSchemaProxyHandler.getPropertySchema(schema, property); + if (propertySchema === null) { continue; } + value[property] = JsonSchemaProxyHandler.getValidValueOrDefault(propertySchema, value[property]); + } + } + + for (const property of properties) { + const propertySchema = JsonSchemaProxyHandler.getPropertySchema(schema, property); + if (propertySchema === null) { + Reflect.deleteProperty(value, property); + } else { + value[property] = JsonSchemaProxyHandler.getValidValueOrDefault(propertySchema, value[property]); + } + } + + return value; + } +} + +class JsonSchema { + static createProxy(target, schema) { + return new Proxy(target, new JsonSchemaProxyHandler(schema)); + } + + static getValidValueOrDefault(schema, value) { + return JsonSchemaProxyHandler.getValidValueOrDefault(schema, value); + } + + static isolate(value) { + if (value === null) { return null; } + + switch (typeof value) { + case 'boolean': + case 'number': + case 'string': + case 'bigint': + case 'symbol': + return value; + } + + const stringValue = JSON.stringify(value); + return typeof stringValue === 'string' ? JSON.parse(stringValue) : null; + } +}