diff options
| -rw-r--r-- | ext/bg/js/json-schema.js | 413 | 
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; +    } +} |