summaryrefslogtreecommitdiff
path: root/ext/bg/js/json-schema.js
diff options
context:
space:
mode:
authortoasted-nutbread <toasted-nutbread@users.noreply.github.com>2019-11-28 12:19:15 -0500
committertoasted-nutbread <toasted-nutbread@users.noreply.github.com>2019-12-29 12:33:15 -0500
commitb770944b127a8b549b94f6ba2b038917acd63eff (patch)
tree35fc71a9b410263a8cda6446dcbc3798a5662604 /ext/bg/js/json-schema.js
parent277e13f22fe892bf4203696248f8304c20dca957 (diff)
Create proxy system for json schema validation
Diffstat (limited to 'ext/bg/js/json-schema.js')
-rw-r--r--ext/bg/js/json-schema.js413
1 files changed, 413 insertions, 0 deletions
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 <alex@foosoft.net>
+ * Author: Alex Yatskov <alex@foosoft.net>
+ *
+ * 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 <http://www.gnu.org/licenses/>.
+ */
+
+
+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;
+ }
+}