From 57ce414ca81682a71288018a4d9001604002ec23 Mon Sep 17 00:00:00 2001 From: Wojtek Kosior Date: Tue, 1 Mar 2022 11:29:26 +0100 Subject: validate repository responses against JSON schemas * compute_scripts.awk (include_file): don't enforce specific path format on #INCLUDE'd files * .gitmodules, schemas: add Haketilo JSON schemas subrepo * html/install.js (InstallView): import schema validator and run it against downloaded mapping and resource definitions * html/repo_query.js (RepoEntry): import schema validator and run it against obtained query results * test/haketilo_test/unit/test_install.py (test_install_normal_usage, test_install_dialogs): use underscore instead of hyphen in item identifiers * test/haketilo_test/unit/test_install.py (test_install_dialogs): adapt error message test cases to new handling method of invalid JSON instanced * test/haketilo_test/unit/test_repo_query.py (test_repo_query_normal_usage): use underscore instead of hyphen in item identifiers * test/haketilo_test/unit/test_repo_query.py (test_repo_query_messages): use higher sample unsupported schema version to avoid having to modify the test case soon * test/haketilo_test/world_wide_library.py: use underscore instead of hyphen in item identifiers * common/jsonschema.js, common/jsonschema: adapt tdegrunt's jsonschema and include in Haketilo, load schema documents from schemas/ --- .gitmodules | 3 + common/jsonschema.js | 83 +++ common/jsonschema/attribute.js | 1002 ++++++++++++++++++++++++++++ common/jsonschema/helpers.js | 429 ++++++++++++ common/jsonschema/scan.js | 99 +++ common/jsonschema/urllib_mock.js | 74 ++ common/jsonschema/validator.js | 360 ++++++++++ compute_scripts.awk | 3 - html/install.js | 42 +- html/repo_query.js | 30 +- schemas | 1 + test/haketilo_test/unit/test_install.py | 70 +- test/haketilo_test/unit/test_popup.py | 2 +- test/haketilo_test/unit/test_repo_query.py | 4 +- test/haketilo_test/world_wide_library.py | 8 +- 15 files changed, 2127 insertions(+), 83 deletions(-) create mode 100644 .gitmodules create mode 100644 common/jsonschema.js create mode 100644 common/jsonschema/attribute.js create mode 100644 common/jsonschema/helpers.js create mode 100644 common/jsonschema/scan.js create mode 100644 common/jsonschema/urllib_mock.js create mode 100644 common/jsonschema/validator.js create mode 160000 schemas diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..edb76b9 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "schemas"] + path = schemas + url = ../hydrilla-json-schemas/ diff --git a/common/jsonschema.js b/common/jsonschema.js new file mode 100644 index 0000000..cde3fca --- /dev/null +++ b/common/jsonschema.js @@ -0,0 +1,83 @@ +/* SPDX-License-Identifier: MIT AND CC0-1.0 + * + * License text of the original lib/index.js from jsonschema library: + * + *************************************** + * + * jsonschema is licensed under MIT license. + * + * Copyright (C) 2012-2015 Tom de Grunt + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies + * of the Software, and to permit persons to whom the Software is furnished to do + * so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + ******************************************************************************* + * + * License notice for the adaptation to use in Haketilo: + * + *************************************** + * + * Copyright (C) 2022 Wojtek Kosior + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the CC0 1.0 Universal License as published by + * the Creative Commons Corporation. + * + * 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 + * CC0 1.0 Universal License for more details. + */ + +#FROM common/jsonschema/validator.js IMPORT Validator +#EXPORT Validator + +#FROM common/jsonschema/helpers.js IMPORT ValidatorResult, ValidationError, \ + ValidatorResultError, SchemaError + +#EXPORT ValidatorResult +#EXPORT ValidationError +#EXPORT ValidatorResultError +#EXPORT SchemaError + +#FROM common/jsonschema/scan.js IMPORT SchemaScanResult, scan + +#EXPORT scan +#EXPORT SchemaScanResult + +function validate(instance, schema, options) { + var v = new Validator(); + return v.validate(instance, schema, options); +}; +#EXPORT validate + +const haketilo_schemas = [ +#INCLUDE schemas/api_query_result-1.0.1.schema.json + , +#INCLUDE schemas/api_mapping_description-1.0.1.schema.json + , +#INCLUDE schemas/api_resource_description-1.0.1.schema.json + , +#INCLUDE schemas/common_definitions-1.0.1.schema.json +].reduce((ac, s) => Object.assign(ac, {[s.$id]: s}), {}); +#EXPORT haketilo_schemas + +const haketilo_validator = new Validator(); +Object.values(haketilo_schemas) + .forEach(s => haketilo_validator.addSchema(s, s.$id)); +#EXPORT haketilo_validator diff --git a/common/jsonschema/attribute.js b/common/jsonschema/attribute.js new file mode 100644 index 0000000..5f10b7c --- /dev/null +++ b/common/jsonschema/attribute.js @@ -0,0 +1,1002 @@ +/* SPDX-License-Identifier: MIT + * + * jsonschema is licensed under MIT license. + * + * Copyright (C) 2012-2015 Tom de Grunt + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies + * of the Software, and to permit persons to whom the Software is furnished to do + * so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +#IMPORT common/jsonschema/helpers.js + +/** @type ValidatorResult */ +var ValidatorResult = helpers.ValidatorResult; +/** @type SchemaError */ +var SchemaError = helpers.SchemaError; + +var attribute = {}; + +const ignoreProperties = { + // informative properties + 'id': true, + 'default': true, + 'description': true, + 'title': true, + // arguments to other properties + 'additionalItems': true, + 'then': true, + 'else': true, + // special-handled properties + '$schema': true, + '$ref': true, + 'extends': true, +}; +#EXPORT ignoreProperties + +/** + * @name validators + */ +const validators = {}; + +/** + * Validates whether the instance if of a certain type + * @param instance + * @param schema + * @param options + * @param ctx + * @return {ValidatorResult|null} + */ +validators.type = function validateType (instance, schema, options, ctx) { + // Ignore undefined instances + if (instance === undefined) { + return null; + } + var result = new ValidatorResult(instance, schema, options, ctx); + var types = Array.isArray(schema.type) ? schema.type : [schema.type]; + if (!types.some(this.testType.bind(this, instance, schema, options, ctx))) { + var list = types.map(function (v) { + if(!v) return; + var id = v.$id || v.id; + return id ? ('<' + id + '>') : (v+''); + }); + result.addError({ + name: 'type', + argument: list, + message: "is not of a type(s) " + list, + }); + } + return result; +}; + +function testSchemaNoThrow(instance, options, ctx, callback, schema){ + var throwError = options.throwError; + var throwAll = options.throwAll; + options.throwError = false; + options.throwAll = false; + var res = this.validateSchema(instance, schema, options, ctx); + options.throwError = throwError; + options.throwAll = throwAll; + + if (!res.valid && callback instanceof Function) { + callback(res); + } + return res.valid; +} + +/** + * Validates whether the instance matches some of the given schemas + * @param instance + * @param schema + * @param options + * @param ctx + * @return {ValidatorResult|null} + */ +validators.anyOf = function validateAnyOf (instance, schema, options, ctx) { + // Ignore undefined instances + if (instance === undefined) { + return null; + } + var result = new ValidatorResult(instance, schema, options, ctx); + var inner = new ValidatorResult(instance, schema, options, ctx); + if (!Array.isArray(schema.anyOf)){ + throw new SchemaError("anyOf must be an array"); + } + if (!schema.anyOf.some( + testSchemaNoThrow.bind( + this, instance, options, ctx, function(res){inner.importErrors(res);} + ))) { + var list = schema.anyOf.map(function (v, i) { + var id = v.$id || v.id; + if(id) return '<' + id + '>'; + return(v.title && JSON.stringify(v.title)) || (v['$ref'] && ('<' + v['$ref'] + '>')) || '[subschema '+i+']'; + }); + if (options.nestedErrors) { + result.importErrors(inner); + } + result.addError({ + name: 'anyOf', + argument: list, + message: "is not any of " + list.join(','), + }); + } + return result; +}; + +/** + * Validates whether the instance matches every given schema + * @param instance + * @param schema + * @param options + * @param ctx + * @return {String|null} + */ +validators.allOf = function validateAllOf (instance, schema, options, ctx) { + // Ignore undefined instances + if (instance === undefined) { + return null; + } + if (!Array.isArray(schema.allOf)){ + throw new SchemaError("allOf must be an array"); + } + var result = new ValidatorResult(instance, schema, options, ctx); + var self = this; + schema.allOf.forEach(function(v, i){ + var valid = self.validateSchema(instance, v, options, ctx); + if(!valid.valid){ + var id = v.$id || v.id; + var msg = id || (v.title && JSON.stringify(v.title)) || (v['$ref'] && ('<' + v['$ref'] + '>')) || '[subschema '+i+']'; + result.addError({ + name: 'allOf', + argument: { id: msg, length: valid.errors.length, valid: valid }, + message: 'does not match allOf schema ' + msg + ' with ' + valid.errors.length + ' error[s]:', + }); + result.importErrors(valid); + } + }); + return result; +}; + +/** + * Validates whether the instance matches exactly one of the given schemas + * @param instance + * @param schema + * @param options + * @param ctx + * @return {String|null} + */ +validators.oneOf = function validateOneOf (instance, schema, options, ctx) { + // Ignore undefined instances + if (instance === undefined) { + return null; + } + if (!Array.isArray(schema.oneOf)){ + throw new SchemaError("oneOf must be an array"); + } + var result = new ValidatorResult(instance, schema, options, ctx); + var inner = new ValidatorResult(instance, schema, options, ctx); + var count = schema.oneOf.filter( + testSchemaNoThrow.bind( + this, instance, options, ctx, function(res) {inner.importErrors(res);} + ) ).length; + var list = schema.oneOf.map(function (v, i) { + var id = v.$id || v.id; + return id || (v.title && JSON.stringify(v.title)) || (v['$ref'] && ('<' + v['$ref'] + '>')) || '[subschema '+i+']'; + }); + if (count!==1) { + if (options.nestedErrors) { + result.importErrors(inner); + } + result.addError({ + name: 'oneOf', + argument: list, + message: "is not exactly one from " + list.join(','), + }); + } + return result; +}; + +/** + * Validates "then" or "else" depending on the result of validating "if" + * @param instance + * @param schema + * @param options + * @param ctx + * @return {String|null} + */ +validators.if = function validateIf (instance, schema, options, ctx) { + // Ignore undefined instances + if (instance === undefined) return null; + if (!helpers.isSchema(schema.if)) throw new Error('Expected "if" keyword to be a schema'); + var ifValid = testSchemaNoThrow.call(this, instance, options, ctx, null, schema.if); + var result = new ValidatorResult(instance, schema, options, ctx); + var res; + if(ifValid){ + if (schema.then === undefined) return; + if (!helpers.isSchema(schema.then)) throw new Error('Expected "then" keyword to be a schema'); + res = this.validateSchema(instance, schema.then, options, ctx.makeChild(schema.then)); + result.importErrors(res); + }else{ + if (schema.else === undefined) return; + if (!helpers.isSchema(schema.else)) throw new Error('Expected "else" keyword to be a schema'); + res = this.validateSchema(instance, schema.else, options, ctx.makeChild(schema.else)); + result.importErrors(res); + } + return result; +}; + +function getEnumerableProperty(object, key){ + // Determine if `key` shows up in `for(var key in object)` + // First test Object.hasOwnProperty.call as an optimization: that guarantees it does + if(Object.hasOwnProperty.call(object, key)) return object[key]; + // Test `key in object` as an optimization; false means it won't + if(!(key in object)) return; + while( (object = Object.getPrototypeOf(object)) ){ + if(Object.propertyIsEnumerable.call(object, key)) return object[key]; + } +} + +/** + * Validates propertyNames + * @param instance + * @param schema + * @param options + * @param ctx + * @return {String|null|ValidatorResult} + */ +validators.propertyNames = function validatePropertyNames (instance, schema, options, ctx) { + if(!this.types.object(instance)) return; + var result = new ValidatorResult(instance, schema, options, ctx); + var subschema = schema.propertyNames!==undefined ? schema.propertyNames : {}; + if(!helpers.isSchema(subschema)) throw new SchemaError('Expected "propertyNames" to be a schema (object or boolean)'); + + for (var property in instance) { + if(getEnumerableProperty(instance, property) !== undefined){ + var res = this.validateSchema(property, subschema, options, ctx.makeChild(subschema)); + result.importErrors(res); + } + } + + return result; +}; + +/** + * Validates properties + * @param instance + * @param schema + * @param options + * @param ctx + * @return {String|null|ValidatorResult} + */ +validators.properties = function validateProperties (instance, schema, options, ctx) { + if(!this.types.object(instance)) return; + var result = new ValidatorResult(instance, schema, options, ctx); + var properties = schema.properties || {}; + for (var property in properties) { + var subschema = properties[property]; + if(subschema===undefined){ + continue; + }else if(subschema===null){ + throw new SchemaError('Unexpected null, expected schema in "properties"'); + } + if (typeof options.preValidateProperty == 'function') { + options.preValidateProperty(instance, property, subschema, options, ctx); + } + var prop = getEnumerableProperty(instance, property); + var res = this.validateSchema(prop, subschema, options, ctx.makeChild(subschema, property)); + if(res.instance !== result.instance[property]) result.instance[property] = res.instance; + result.importErrors(res); + } + return result; +}; + +/** + * Test a specific property within in instance against the additionalProperties schema attribute + * This ignores properties with definitions in the properties schema attribute, but no other attributes. + * If too many more types of property-existence tests pop up they may need their own class of tests (like `type` has) + * @private + * @return {boolean} + */ +function testAdditionalProperty (instance, schema, options, ctx, property, result) { + if(!this.types.object(instance)) return; + if (schema.properties && schema.properties[property] !== undefined) { + return; + } + if (schema.additionalProperties === false) { + result.addError({ + name: 'additionalProperties', + argument: property, + message: "is not allowed to have the additional property " + JSON.stringify(property), + }); + } else { + var additionalProperties = schema.additionalProperties || {}; + + if (typeof options.preValidateProperty == 'function') { + options.preValidateProperty(instance, property, additionalProperties, options, ctx); + } + + var res = this.validateSchema(instance[property], additionalProperties, options, ctx.makeChild(additionalProperties, property)); + if(res.instance !== result.instance[property]) result.instance[property] = res.instance; + result.importErrors(res); + } +} + +/** + * Validates patternProperties + * @param instance + * @param schema + * @param options + * @param ctx + * @return {String|null|ValidatorResult} + */ +validators.patternProperties = function validatePatternProperties (instance, schema, options, ctx) { + if(!this.types.object(instance)) return; + var result = new ValidatorResult(instance, schema, options, ctx); + var patternProperties = schema.patternProperties || {}; + + for (var property in instance) { + var test = true; + for (var pattern in patternProperties) { + var subschema = patternProperties[pattern]; + if(subschema===undefined){ + continue; + }else if(subschema===null){ + throw new SchemaError('Unexpected null, expected schema in "patternProperties"'); + } + try { + var regexp = new RegExp(pattern, 'u'); + } catch(_e) { + // In the event the stricter handling causes an error, fall back on the forgiving handling + // DEPRECATED + regexp = new RegExp(pattern); + } + if (!regexp.test(property)) { + continue; + } + test = false; + + if (typeof options.preValidateProperty == 'function') { + options.preValidateProperty(instance, property, subschema, options, ctx); + } + + var res = this.validateSchema(instance[property], subschema, options, ctx.makeChild(subschema, property)); + if(res.instance !== result.instance[property]) result.instance[property] = res.instance; + result.importErrors(res); + } + if (test) { + testAdditionalProperty.call(this, instance, schema, options, ctx, property, result); + } + } + + return result; +}; + +/** + * Validates additionalProperties + * @param instance + * @param schema + * @param options + * @param ctx + * @return {String|null|ValidatorResult} + */ +validators.additionalProperties = function validateAdditionalProperties (instance, schema, options, ctx) { + if(!this.types.object(instance)) return; + // if patternProperties is defined then we'll test when that one is called instead + if (schema.patternProperties) { + return null; + } + var result = new ValidatorResult(instance, schema, options, ctx); + for (var property in instance) { + testAdditionalProperty.call(this, instance, schema, options, ctx, property, result); + } + return result; +}; + +/** + * Validates whether the instance value is at least of a certain length, when the instance value is a string. + * @param instance + * @param schema + * @return {String|null} + */ +validators.minProperties = function validateMinProperties (instance, schema, options, ctx) { + if (!this.types.object(instance)) return; + var result = new ValidatorResult(instance, schema, options, ctx); + var keys = Object.keys(instance); + if (!(keys.length >= schema.minProperties)) { + result.addError({ + name: 'minProperties', + argument: schema.minProperties, + message: "does not meet minimum property length of " + schema.minProperties, + }); + } + return result; +}; + +/** + * Validates whether the instance value is at most of a certain length, when the instance value is a string. + * @param instance + * @param schema + * @return {String|null} + */ +validators.maxProperties = function validateMaxProperties (instance, schema, options, ctx) { + if (!this.types.object(instance)) return; + var result = new ValidatorResult(instance, schema, options, ctx); + var keys = Object.keys(instance); + if (!(keys.length <= schema.maxProperties)) { + result.addError({ + name: 'maxProperties', + argument: schema.maxProperties, + message: "does not meet maximum property length of " + schema.maxProperties, + }); + } + return result; +}; + +/** + * Validates items when instance is an array + * @param instance + * @param schema + * @param options + * @param ctx + * @return {String|null|ValidatorResult} + */ +validators.items = function validateItems (instance, schema, options, ctx) { + var self = this; + if (!this.types.array(instance)) return; + if (schema.items===undefined) return; + var result = new ValidatorResult(instance, schema, options, ctx); + instance.every(function (value, i) { + if(Array.isArray(schema.items)){ + var items = schema.items[i]===undefined ? schema.additionalItems : schema.items[i]; + }else{ + var items = schema.items; + } + if (items === undefined) { + return true; + } + if (items === false) { + result.addError({ + name: 'items', + message: "additionalItems not permitted", + }); + return false; + } + var res = self.validateSchema(value, items, options, ctx.makeChild(items, i)); + if(res.instance !== result.instance[i]) result.instance[i] = res.instance; + result.importErrors(res); + return true; + }); + return result; +}; + +/** + * Validates the "contains" keyword + * @param instance + * @param schema + * @param options + * @param ctx + * @return {String|null|ValidatorResult} + */ +validators.contains = function validateContains (instance, schema, options, ctx) { + var self = this; + if (!this.types.array(instance)) return; + if (schema.contains===undefined) return; + if (!helpers.isSchema(schema.contains)) throw new Error('Expected "contains" keyword to be a schema'); + var result = new ValidatorResult(instance, schema, options, ctx); + var count = instance.some(function (value, i) { + var res = self.validateSchema(value, schema.contains, options, ctx.makeChild(schema.contains, i)); + return res.errors.length===0; + }); + if(count===false){ + result.addError({ + name: 'contains', + argument: schema.contains, + message: "must contain an item matching given schema", + }); + } + return result; +}; + +/** + * Validates minimum and exclusiveMinimum when the type of the instance value is a number. + * @param instance + * @param schema + * @return {String|null} + */ +validators.minimum = function validateMinimum (instance, schema, options, ctx) { + if (!this.types.number(instance)) return; + var result = new ValidatorResult(instance, schema, options, ctx); + if (schema.exclusiveMinimum && schema.exclusiveMinimum === true) { + if(!(instance > schema.minimum)){ + result.addError({ + name: 'minimum', + argument: schema.minimum, + message: "must be greater than " + schema.minimum, + }); + } + } else { + if(!(instance >= schema.minimum)){ + result.addError({ + name: 'minimum', + argument: schema.minimum, + message: "must be greater than or equal to " + schema.minimum, + }); + } + } + return result; +}; + +/** + * Validates maximum and exclusiveMaximum when the type of the instance value is a number. + * @param instance + * @param schema + * @return {String|null} + */ +validators.maximum = function validateMaximum (instance, schema, options, ctx) { + if (!this.types.number(instance)) return; + var result = new ValidatorResult(instance, schema, options, ctx); + if (schema.exclusiveMaximum && schema.exclusiveMaximum === true) { + if(!(instance < schema.maximum)){ + result.addError({ + name: 'maximum', + argument: schema.maximum, + message: "must be less than " + schema.maximum, + }); + } + } else { + if(!(instance <= schema.maximum)){ + result.addError({ + name: 'maximum', + argument: schema.maximum, + message: "must be less than or equal to " + schema.maximum, + }); + } + } + return result; +}; + +/** + * Validates the number form of exclusiveMinimum when the type of the instance value is a number. + * @param instance + * @param schema + * @return {String|null} + */ +validators.exclusiveMinimum = function validateExclusiveMinimum (instance, schema, options, ctx) { + // Support the boolean form of exclusiveMinimum, which is handled by the "minimum" keyword. + if(typeof schema.exclusiveMinimum === 'boolean') return; + if (!this.types.number(instance)) return; + var result = new ValidatorResult(instance, schema, options, ctx); + var valid = instance > schema.exclusiveMinimum; + if (!valid) { + result.addError({ + name: 'exclusiveMinimum', + argument: schema.exclusiveMinimum, + message: "must be strictly greater than " + schema.exclusiveMinimum, + }); + } + return result; +}; + +/** + * Validates the number form of exclusiveMaximum when the type of the instance value is a number. + * @param instance + * @param schema + * @return {String|null} + */ +validators.exclusiveMaximum = function validateExclusiveMaximum (instance, schema, options, ctx) { + // Support the boolean form of exclusiveMaximum, which is handled by the "maximum" keyword. + if(typeof schema.exclusiveMaximum === 'boolean') return; + if (!this.types.number(instance)) return; + var result = new ValidatorResult(instance, schema, options, ctx); + var valid = instance < schema.exclusiveMaximum; + if (!valid) { + result.addError({ + name: 'exclusiveMaximum', + argument: schema.exclusiveMaximum, + message: "must be strictly less than " + schema.exclusiveMaximum, + }); + } + return result; +}; + +/** + * Perform validation for multipleOf and divisibleBy, which are essentially the same. + * @param instance + * @param schema + * @param validationType + * @param errorMessage + * @returns {String|null} + */ +var validateMultipleOfOrDivisbleBy = function validateMultipleOfOrDivisbleBy (instance, schema, options, ctx, validationType, errorMessage) { + if (!this.types.number(instance)) return; + + var validationArgument = schema[validationType]; + if (validationArgument == 0) { + throw new SchemaError(validationType + " cannot be zero"); + } + + var result = new ValidatorResult(instance, schema, options, ctx); + + var instanceDecimals = helpers.getDecimalPlaces(instance); + var divisorDecimals = helpers.getDecimalPlaces(validationArgument); + + var maxDecimals = Math.max(instanceDecimals , divisorDecimals); + var multiplier = Math.pow(10, maxDecimals); + + if (Math.round(instance * multiplier) % Math.round(validationArgument * multiplier) !== 0) { + result.addError({ + name: validationType, + argument: validationArgument, + message: errorMessage + JSON.stringify(validationArgument), + }); + } + + return result; +}; + +/** + * Validates divisibleBy when the type of the instance value is a number. + * @param instance + * @param schema + * @return {String|null} + */ +validators.multipleOf = function validateMultipleOf (instance, schema, options, ctx) { + return validateMultipleOfOrDivisbleBy.call(this, instance, schema, options, ctx, "multipleOf", "is not a multiple of (divisible by) "); +}; + +/** + * Validates multipleOf when the type of the instance value is a number. + * @param instance + * @param schema + * @return {String|null} + */ +validators.divisibleBy = function validateDivisibleBy (instance, schema, options, ctx) { + return validateMultipleOfOrDivisbleBy.call(this, instance, schema, options, ctx, "divisibleBy", "is not divisible by (multiple of) "); +}; + +/** + * Validates whether the instance value is present. + * @param instance + * @param schema + * @return {String|null} + */ +validators.required = function validateRequired (instance, schema, options, ctx) { + var result = new ValidatorResult(instance, schema, options, ctx); + if (instance === undefined && schema.required === true) { + // A boolean form is implemented for reverse-compatibility with schemas written against older drafts + result.addError({ + name: 'required', + message: "is required", + }); + } else if (this.types.object(instance) && Array.isArray(schema.required)) { + schema.required.forEach(function(n){ + if(getEnumerableProperty(instance, n)===undefined){ + result.addError({ + name: 'required', + argument: n, + message: "requires property " + JSON.stringify(n), + }); + } + }); + } + return result; +}; + +/** + * Validates whether the instance value matches the regular expression, when the instance value is a string. + * @param instance + * @param schema + * @return {String|null} + */ +validators.pattern = function validatePattern (instance, schema, options, ctx) { + if (!this.types.string(instance)) return; + var result = new ValidatorResult(instance, schema, options, ctx); + var pattern = schema.pattern; + try { + var regexp = new RegExp(pattern, 'u'); + } catch(_e) { + // In the event the stricter handling causes an error, fall back on the forgiving handling + // DEPRECATED + regexp = new RegExp(pattern); + } + if (!instance.match(regexp)) { + result.addError({ + name: 'pattern', + argument: schema.pattern, + message: "does not match pattern " + JSON.stringify(schema.pattern.toString()), + }); + } + return result; +}; + +/** + * Validates whether the instance value is of a certain defined format or a custom + * format. + * The following formats are supported for string types: + * - date-time + * - date + * - time + * - ip-address + * - ipv6 + * - uri + * - color + * - host-name + * - alpha + * - alpha-numeric + * - utc-millisec + * @param instance + * @param schema + * @param [options] + * @param [ctx] + * @return {String|null} + */ +validators.format = function validateFormat (instance, schema, options, ctx) { + if (instance===undefined) return; + var result = new ValidatorResult(instance, schema, options, ctx); + if (!result.disableFormat && !helpers.isFormat(instance, schema.format, this)) { + result.addError({ + name: 'format', + argument: schema.format, + message: "does not conform to the " + JSON.stringify(schema.format) + " format", + }); + } + return result; +}; + +/** + * Validates whether the instance value is at least of a certain length, when the instance value is a string. + * @param instance + * @param schema + * @return {String|null} + */ +validators.minLength = function validateMinLength (instance, schema, options, ctx) { + if (!this.types.string(instance)) return; + var result = new ValidatorResult(instance, schema, options, ctx); + var hsp = instance.match(/[\uDC00-\uDFFF]/g); + var length = instance.length - (hsp ? hsp.length : 0); + if (!(length >= schema.minLength)) { + result.addError({ + name: 'minLength', + argument: schema.minLength, + message: "does not meet minimum length of " + schema.minLength, + }); + } + return result; +}; + +/** + * Validates whether the instance value is at most of a certain length, when the instance value is a string. + * @param instance + * @param schema + * @return {String|null} + */ +validators.maxLength = function validateMaxLength (instance, schema, options, ctx) { + if (!this.types.string(instance)) return; + var result = new ValidatorResult(instance, schema, options, ctx); + // TODO if this was already computed in "minLength", use that value instead of re-computing + var hsp = instance.match(/[\uDC00-\uDFFF]/g); + var length = instance.length - (hsp ? hsp.length : 0); + if (!(length <= schema.maxLength)) { + result.addError({ + name: 'maxLength', + argument: schema.maxLength, + message: "does not meet maximum length of " + schema.maxLength, + }); + } + return result; +}; + +/** + * Validates whether instance contains at least a minimum number of items, when the instance is an Array. + * @param instance + * @param schema + * @return {String|null} + */ +validators.minItems = function validateMinItems (instance, schema, options, ctx) { + if (!this.types.array(instance)) return; + var result = new ValidatorResult(instance, schema, options, ctx); + if (!(instance.length >= schema.minItems)) { + result.addError({ + name: 'minItems', + argument: schema.minItems, + message: "does not meet minimum length of " + schema.minItems, + }); + } + return result; +}; + +/** + * Validates whether instance contains no more than a maximum number of items, when the instance is an Array. + * @param instance + * @param schema + * @return {String|null} + */ +validators.maxItems = function validateMaxItems (instance, schema, options, ctx) { + if (!this.types.array(instance)) return; + var result = new ValidatorResult(instance, schema, options, ctx); + if (!(instance.length <= schema.maxItems)) { + result.addError({ + name: 'maxItems', + argument: schema.maxItems, + message: "does not meet maximum length of " + schema.maxItems, + }); + } + return result; +}; + +/** + * Deep compares arrays for duplicates + * @param v + * @param i + * @param a + * @private + * @return {boolean} + */ +function testArrays (v, i, a) { + var j, len = a.length; + for (j = i + 1, len; j < len; j++) { + if (helpers.deepCompareStrict(v, a[j])) { + return false; + } + } + return true; +} + +/** + * Validates whether there are no duplicates, when the instance is an Array. + * @param instance + * @return {String|null} + */ +validators.uniqueItems = function validateUniqueItems (instance, schema, options, ctx) { + if (schema.uniqueItems!==true) return; + if (!this.types.array(instance)) return; + var result = new ValidatorResult(instance, schema, options, ctx); + if (!instance.every(testArrays)) { + result.addError({ + name: 'uniqueItems', + message: "contains duplicate item", + }); + } + return result; +}; + +/** + * Validate for the presence of dependency properties, if the instance is an object. + * @param instance + * @param schema + * @param options + * @param ctx + * @return {null|ValidatorResult} + */ +validators.dependencies = function validateDependencies (instance, schema, options, ctx) { + if (!this.types.object(instance)) return; + var result = new ValidatorResult(instance, schema, options, ctx); + for (var property in schema.dependencies) { + if (instance[property] === undefined) { + continue; + } + var dep = schema.dependencies[property]; + var childContext = ctx.makeChild(dep, property); + if (typeof dep == 'string') { + dep = [dep]; + } + if (Array.isArray(dep)) { + dep.forEach(function (prop) { + if (instance[prop] === undefined) { + result.addError({ + // FIXME there's two different "dependencies" errors here with slightly different outputs + // Can we make these the same? Or should we create different error types? + name: 'dependencies', + argument: childContext.propertyPath, + message: "property " + prop + " not found, required by " + childContext.propertyPath, + }); + } + }); + } else { + var res = this.validateSchema(instance, dep, options, childContext); + if(result.instance !== res.instance) result.instance = res.instance; + if (res && res.errors.length) { + result.addError({ + name: 'dependencies', + argument: childContext.propertyPath, + message: "does not meet dependency required by " + childContext.propertyPath, + }); + result.importErrors(res); + } + } + } + return result; +}; + +/** + * Validates whether the instance value is one of the enumerated values. + * + * @param instance + * @param schema + * @return {ValidatorResult|null} + */ +validators['enum'] = function validateEnum (instance, schema, options, ctx) { + if (instance === undefined) { + return null; + } + if (!Array.isArray(schema['enum'])) { + throw new SchemaError("enum expects an array", schema); + } + var result = new ValidatorResult(instance, schema, options, ctx); + if (!schema['enum'].some(helpers.deepCompareStrict.bind(null, instance))) { + result.addError({ + name: 'enum', + argument: schema['enum'], + message: "is not one of enum values: " + schema['enum'].map(String).join(','), + }); + } + return result; +}; + +/** + * Validates whether the instance exactly matches a given value + * + * @param instance + * @param schema + * @return {ValidatorResult|null} + */ +validators['const'] = function validateEnum (instance, schema, options, ctx) { + if (instance === undefined) { + return null; + } + var result = new ValidatorResult(instance, schema, options, ctx); + if (!helpers.deepCompareStrict(schema['const'], instance)) { + result.addError({ + name: 'const', + argument: schema['const'], + message: "does not exactly match expected constant: " + schema['const'], + }); + } + return result; +}; + +/** + * Validates whether the instance if of a prohibited type. + * @param instance + * @param schema + * @param options + * @param ctx + * @return {null|ValidatorResult} + */ +validators.not = validators.disallow = function validateNot (instance, schema, options, ctx) { + var self = this; + if(instance===undefined) return null; + var result = new ValidatorResult(instance, schema, options, ctx); + var notTypes = schema.not || schema.disallow; + if(!notTypes) return null; + if(!Array.isArray(notTypes)) notTypes=[notTypes]; + notTypes.forEach(function (type) { + if (self.testType(instance, schema, options, ctx, type)) { + var id = type && (type.$id || type.id); + var schemaId = id || type; + result.addError({ + name: 'not', + argument: schemaId, + message: "is of prohibited type " + schemaId, + }); + } + }); + return result; +}; + +#EXPORT validators diff --git a/common/jsonschema/helpers.js b/common/jsonschema/helpers.js new file mode 100644 index 0000000..f6a72a6 --- /dev/null +++ b/common/jsonschema/helpers.js @@ -0,0 +1,429 @@ +/* SPDX-License-Identifier: MIT + * + * jsonschema is licensed under MIT license. + * + * Copyright (C) 2012-2015 Tom de Grunt + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies + * of the Software, and to permit persons to whom the Software is furnished to do + * so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +#IMPORT common/jsonschema/urllib_mock.js AS uri + +function ValidationError (message, instance, schema, path, name, argument) { + if(Array.isArray(path)){ + this.path = path; + this.property = path.reduce(function(sum, item){ + return sum + makeSuffix(item); + }, 'instance'); + }else if(path !== undefined){ + this.property = path; + } + if (message) { + this.message = message; + } + if (schema) { + var id = schema.$id || schema.id; + this.schema = id || schema; + } + if (instance !== undefined) { + this.instance = instance; + } + this.name = name; + this.argument = argument; + this.stack = this.toString(); +}; +#EXPORT ValidationError + +ValidationError.prototype.toString = function toString() { + return this.property + ' ' + this.message; +}; + +function ValidatorResult(instance, schema, options, ctx) { + this.instance = instance; + this.schema = schema; + this.options = options; + this.path = ctx.path; + this.propertyPath = ctx.propertyPath; + this.errors = []; + this.throwError = options && options.throwError; + this.throwFirst = options && options.throwFirst; + this.throwAll = options && options.throwAll; + this.disableFormat = options && options.disableFormat === true; +}; + +ValidatorResult.prototype.addError = function addError(detail) { + var err; + if (typeof detail == 'string') { + err = new ValidationError(detail, this.instance, this.schema, this.path); + } else { + if (!detail) throw new Error('Missing error detail'); + if (!detail.message) throw new Error('Missing error message'); + if (!detail.name) throw new Error('Missing validator type'); + err = new ValidationError(detail.message, this.instance, this.schema, this.path, detail.name, detail.argument); + } + + this.errors.push(err); + if (this.throwFirst) { + throw new ValidatorResultError(this); + }else if(this.throwError){ + throw err; + } + return err; +}; + +ValidatorResult.prototype.importErrors = function importErrors(res) { + if (typeof res == 'string' || (res && res.validatorType)) { + this.addError(res); + } else if (res && res.errors) { + Array.prototype.push.apply(this.errors, res.errors); + } +}; + +function stringizer (v,i){ + return i+': '+v.toString()+'\n'; +} +ValidatorResult.prototype.toString = function toString(res) { + return this.errors.map(stringizer).join(''); +}; + +Object.defineProperty(ValidatorResult.prototype, "valid", { get: function() { + return !this.errors.length; +} }); + +#EXPORT ValidatorResult + +function ValidatorResultError(result) { + if(Error.captureStackTrace){ + Error.captureStackTrace(this, ValidatorResultError); + } + this.instance = result.instance; + this.schema = result.schema; + this.options = result.options; + this.errors = result.errors; +} +ValidatorResultError.prototype = new Error(); +ValidatorResultError.prototype.constructor = ValidatorResultError; +ValidatorResultError.prototype.name = "Validation Error"; +#EXPORT ValidatorResultError + +/** + * Describes a problem with a Schema which prevents validation of an instance + * @name SchemaError + * @constructor + */ +function SchemaError (msg, schema) { + this.message = msg; + this.schema = schema; + Error.call(this, msg); + if(Error.captureStackTrace){ + Error.captureStackTrace(this, SchemaError); + } +}; +SchemaError.prototype = Object.create(Error.prototype, + { + constructor: {value: SchemaError, enumerable: false}, + name: {value: 'SchemaError', enumerable: false}, + }); +#EXPORT SchemaError + +function SchemaContext (schema, options, path, base, schemas) { + this.schema = schema; + this.options = options; + if(Array.isArray(path)){ + this.path = path; + this.propertyPath = path.reduce(function(sum, item){ + return sum + makeSuffix(item); + }, 'instance'); + }else{ + this.propertyPath = path; + } + this.base = base; + this.schemas = schemas; +}; + +SchemaContext.prototype.resolve = function resolve (target) { + return uri.resolve(this.base, target); +}; + +SchemaContext.prototype.makeChild = function makeChild(schema, propertyName){ + var path = (propertyName===undefined) ? this.path : this.path.concat([propertyName]); + var id = schema.$id || schema.id; + var base = uri.resolve(this.base, id||''); + var ctx = new SchemaContext(schema, this.options, path, base, Object.create(this.schemas)); + if(id && !ctx.schemas[base]){ + ctx.schemas[base] = schema; + } + return ctx; +}; + +#EXPORT SchemaContext + +const FORMAT_REGEXPS = { + // 7.3.1. Dates, Times, and Duration + 'date-time': /^\d{4}-(?:0[0-9]{1}|1[0-2]{1})-(3[01]|0[1-9]|[12][0-9])[tT ](2[0-4]|[01][0-9]):([0-5][0-9]):(60|[0-5][0-9])(\.\d+)?([zZ]|[+-]([0-5][0-9]):(60|[0-5][0-9]))$/, + 'date': /^\d{4}-(?:0[0-9]{1}|1[0-2]{1})-(3[01]|0[1-9]|[12][0-9])$/, + 'time': /^(2[0-4]|[01][0-9]):([0-5][0-9]):(60|[0-5][0-9])$/, + 'duration': /P(T\d+(H(\d+M(\d+S)?)?|M(\d+S)?|S)|\d+(D|M(\d+D)?|Y(\d+M(\d+D)?)?)(T\d+(H(\d+M(\d+S)?)?|M(\d+S)?|S))?|\d+W)/i, + + // 7.3.2. Email Addresses + // TODO: fix the email production + 'email': /^(?:[\w\!\#\$\%\&\'\*\+\-\/\=\?\^\`\{\|\}\~]+\.)*[\w\!\#\$\%\&\'\*\+\-\/\=\?\^\`\{\|\}\~]+@(?:(?:(?:[a-zA-Z0-9](?:[a-zA-Z0-9\-](?!\.)){0,61}[a-zA-Z0-9]?\.)+[a-zA-Z0-9](?:[a-zA-Z0-9\-](?!$)){0,61}[a-zA-Z0-9]?)|(?:\[(?:(?:[01]?\d{1,2}|2[0-4]\d|25[0-5])\.){3}(?:[01]?\d{1,2}|2[0-4]\d|25[0-5])\]))$/, + 'idn-email': /^("(?:[!#-\[\]-\u{10FFFF}]|\\[\t -\u{10FFFF}])*"|[!#-'*+\-/-9=?A-Z\^-\u{10FFFF}](?:\.?[!#-'*+\-/-9=?A-Z\^-\u{10FFFF}])*)@([!#-'*+\-/-9=?A-Z\^-\u{10FFFF}](?:\.?[!#-'*+\-/-9=?A-Z\^-\u{10FFFF}])*|\[[!-Z\^-\u{10FFFF}]*\])$/u, + + // 7.3.3. Hostnames + + // 7.3.4. IP Addresses + 'ip-address': /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/, + // FIXME whitespace is invalid + 'ipv6': /^\s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:)))(%.+)?\s*$/, + + // 7.3.5. Resource Identifiers + // TODO: A more accurate regular expression for "uri" goes: + // [A-Za-z][+\-.0-9A-Za-z]*:((/(/((%[0-9A-Fa-f]{2}|[!$&-.0-9;=A-Z_a-z~])+|(\[(([Vv][0-9A-Fa-f]+\.[!$&-.0-;=A-Z_a-z~]+)?|[.0-:A-Fa-f]+)\])?)(:\d*)?)?)?#(%[0-9A-Fa-f]{2}|[!$&-;=?-Z_a-z~])*|(/(/((%[0-9A-Fa-f]{2}|[!$&-.0-9;=A-Z_a-z~])+|(\[(([Vv][0-9A-Fa-f]+\.[!$&-.0-;=A-Z_a-z~]+)?|[.0-:A-Fa-f]+)\])?)(:\d*)?[/?]|[!$&-.0-;=?-Z_a-z~])|/?%[0-9A-Fa-f]{2}|[!$&-.0-;=?-Z_a-z~])(%[0-9A-Fa-f]{2}|[!$&-;=?-Z_a-z~])*(#(%[0-9A-Fa-f]{2}|[!$&-;=?-Z_a-z~])*)?|/(/((%[0-9A-Fa-f]{2}|[!$&-.0-9;=A-Z_a-z~])+(:\d*)?|(\[(([Vv][0-9A-Fa-f]+\.[!$&-.0-;=A-Z_a-z~]+)?|[.0-:A-Fa-f]+)\])?:\d*|\[(([Vv][0-9A-Fa-f]+\.[!$&-.0-;=A-Z_a-z~]+)?|[.0-:A-Fa-f]+)\])?)?)? + 'uri': /^[a-zA-Z][a-zA-Z0-9+.-]*:[^\s]*$/, + 'uri-reference': /^(((([A-Za-z][+\-.0-9A-Za-z]*(:%[0-9A-Fa-f]{2}|:[!$&-.0-;=?-Z_a-z~]|[/?])|\?)(%[0-9A-Fa-f]{2}|[!$&-;=?-Z_a-z~])*|([A-Za-z][+\-.0-9A-Za-z]*:?)?)|([A-Za-z][+\-.0-9A-Za-z]*:)?\/((%[0-9A-Fa-f]{2}|\/((%[0-9A-Fa-f]{2}|[!$&-.0-9;=A-Z_a-z~])+|(\[(([Vv][0-9A-Fa-f]+\.[!$&-.0-;=A-Z_a-z~]+)?|[.0-:A-Fa-f]+)\])?)(:\d*)?[/?]|[!$&-.0-;=?-Z_a-z~])(%[0-9A-Fa-f]{2}|[!$&-;=?-Z_a-z~])*|(\/((%[0-9A-Fa-f]{2}|[!$&-.0-9;=A-Z_a-z~])+|(\[(([Vv][0-9A-Fa-f]+\.[!$&-.0-;=A-Z_a-z~]+)?|[.0-:A-Fa-f]+)\])?)(:\d*)?)?))#(%[0-9A-Fa-f]{2}|[!$&-;=?-Z_a-z~])*|(([A-Za-z][+\-.0-9A-Za-z]*)?%[0-9A-Fa-f]{2}|[!$&-.0-9;=@_~]|[A-Za-z][+\-.0-9A-Za-z]*[!$&-*,;=@_~])(%[0-9A-Fa-f]{2}|[!$&-.0-9;=@-Z_a-z~])*((([/?](%[0-9A-Fa-f]{2}|[!$&-;=?-Z_a-z~])*)?#|[/?])(%[0-9A-Fa-f]{2}|[!$&-;=?-Z_a-z~])*)?|([A-Za-z][+\-.0-9A-Za-z]*(:%[0-9A-Fa-f]{2}|:[!$&-.0-;=?-Z_a-z~]|[/?])|\?)(%[0-9A-Fa-f]{2}|[!$&-;=?-Z_a-z~])*|([A-Za-z][+\-.0-9A-Za-z]*:)?\/((%[0-9A-Fa-f]{2}|\/((%[0-9A-Fa-f]{2}|[!$&-.0-9;=A-Z_a-z~])+|(\[(([Vv][0-9A-Fa-f]+\.[!$&-.0-;=A-Z_a-z~]+)?|[.0-:A-Fa-f]+)\])?)(:\d*)?[/?]|[!$&-.0-;=?-Z_a-z~])(%[0-9A-Fa-f]{2}|[!$&-;=?-Z_a-z~])*|\/((%[0-9A-Fa-f]{2}|[!$&-.0-9;=A-Z_a-z~])+(:\d*)?|(\[(([Vv][0-9A-Fa-f]+\.[!$&-.0-;=A-Z_a-z~]+)?|[.0-:A-Fa-f]+)\])?:\d*|\[(([Vv][0-9A-Fa-f]+\.[!$&-.0-;=A-Z_a-z~]+)?|[.0-:A-Fa-f]+)\])?)?|[A-Za-z][+\-.0-9A-Za-z]*:?)?$/, + 'iri': /^[a-zA-Z][a-zA-Z0-9+.-]*:[^\s]*$/, + 'iri-reference': /^(((([A-Za-z][+\-.0-9A-Za-z]*(:%[0-9A-Fa-f]{2}|:[!$&-.0-;=?-Z_a-z~-\u{10FFFF}]|[/?])|\?)(%[0-9A-Fa-f]{2}|[!$&-;=?-Z_a-z~-\u{10FFFF}])*|([A-Za-z][+\-.0-9A-Za-z]*:?)?)|([A-Za-z][+\-.0-9A-Za-z]*:)?\/((%[0-9A-Fa-f]{2}|\/((%[0-9A-Fa-f]{2}|[!$&-.0-9;=A-Z_a-z~-\u{10FFFF}])+|(\[(([Vv][0-9A-Fa-f]+\.[!$&-.0-;=A-Z_a-z~-\u{10FFFF}]+)?|[.0-:A-Fa-f]+)\])?)(:\d*)?[/?]|[!$&-.0-;=?-Z_a-z~-\u{10FFFF}])(%[0-9A-Fa-f]{2}|[!$&-;=?-Z_a-z~-\u{10FFFF}])*|(\/((%[0-9A-Fa-f]{2}|[!$&-.0-9;=A-Z_a-z~-\u{10FFFF}])+|(\[(([Vv][0-9A-Fa-f]+\.[!$&-.0-;=A-Z_a-z~-\u{10FFFF}]+)?|[.0-:A-Fa-f]+)\])?)(:\d*)?)?))#(%[0-9A-Fa-f]{2}|[!$&-;=?-Z_a-z~-\u{10FFFF}])*|(([A-Za-z][+\-.0-9A-Za-z]*)?%[0-9A-Fa-f]{2}|[!$&-.0-9;=@_~-\u{10FFFF}]|[A-Za-z][+\-.0-9A-Za-z]*[!$&-*,;=@_~-\u{10FFFF}])(%[0-9A-Fa-f]{2}|[!$&-.0-9;=@-Z_a-z~-\u{10FFFF}])*((([/?](%[0-9A-Fa-f]{2}|[!$&-;=?-Z_a-z~-\u{10FFFF}])*)?#|[/?])(%[0-9A-Fa-f]{2}|[!$&-;=?-Z_a-z~-\u{10FFFF}])*)?|([A-Za-z][+\-.0-9A-Za-z]*(:%[0-9A-Fa-f]{2}|:[!$&-.0-;=?-Z_a-z~-\u{10FFFF}]|[/?])|\?)(%[0-9A-Fa-f]{2}|[!$&-;=?-Z_a-z~-\u{10FFFF}])*|([A-Za-z][+\-.0-9A-Za-z]*:)?\/((%[0-9A-Fa-f]{2}|\/((%[0-9A-Fa-f]{2}|[!$&-.0-9;=A-Z_a-z~-\u{10FFFF}])+|(\[(([Vv][0-9A-Fa-f]+\.[!$&-.0-;=A-Z_a-z~-\u{10FFFF}]+)?|[.0-:A-Fa-f]+)\])?)(:\d*)?[/?]|[!$&-.0-;=?-Z_a-z~-\u{10FFFF}])(%[0-9A-Fa-f]{2}|[!$&-;=?-Z_a-z~-\u{10FFFF}])*|\/((%[0-9A-Fa-f]{2}|[!$&-.0-9;=A-Z_a-z~-\u{10FFFF}])+(:\d*)?|(\[(([Vv][0-9A-Fa-f]+\.[!$&-.0-;=A-Z_a-z~-\u{10FFFF}]+)?|[.0-:A-Fa-f]+)\])?:\d*|\[(([Vv][0-9A-Fa-f]+\.[!$&-.0-;=A-Z_a-z~-\u{10FFFF}]+)?|[.0-:A-Fa-f]+)\])?)?|[A-Za-z][+\-.0-9A-Za-z]*:?)?$/u, + 'uuid': /^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/i, + + // 7.3.6. uri-template + 'uri-template': /(%[0-9a-f]{2}|[!#$&(-;=?@\[\]_a-z~]|\{[!#&+,./;=?@|]?(%[0-9a-f]{2}|[0-9_a-z])(\.?(%[0-9a-f]{2}|[0-9_a-z]))*(:[1-9]\d{0,3}|\*)?(,(%[0-9a-f]{2}|[0-9_a-z])(\.?(%[0-9a-f]{2}|[0-9_a-z]))*(:[1-9]\d{0,3}|\*)?)*\})*/iu, + + // 7.3.7. JSON Pointers + 'json-pointer': /^(\/([\x00-\x2e0-@\[-}\x7f]|~[01])*)*$/iu, + 'relative-json-pointer': /^\d+(#|(\/([\x00-\x2e0-@\[-}\x7f]|~[01])*)*)$/iu, + + // hostname regex from: http://stackoverflow.com/a/1420225/5628 + 'hostname': /^(?=.{1,255}$)[0-9A-Za-z](?:(?:[0-9A-Za-z]|-){0,61}[0-9A-Za-z])?(?:\.[0-9A-Za-z](?:(?:[0-9A-Za-z]|-){0,61}[0-9A-Za-z])?)*\.?$/, + 'host-name': /^(?=.{1,255}$)[0-9A-Za-z](?:(?:[0-9A-Za-z]|-){0,61}[0-9A-Za-z])?(?:\.[0-9A-Za-z](?:(?:[0-9A-Za-z]|-){0,61}[0-9A-Za-z])?)*\.?$/, + + 'utc-millisec': function (input) { + return (typeof input === 'string') && parseFloat(input) === parseInt(input, 10) && !isNaN(input); + }, + + // 7.3.8. regex + 'regex': function (input) { + var result = true; + try { + new RegExp(input); + } catch (e) { + result = false; + } + return result; + }, + + // Other definitions + // "style" was removed from JSON Schema in draft-4 and is deprecated + 'style': /[\r\n\t ]*[^\r\n\t ][^:]*:[\r\n\t ]*[^\r\n\t ;]*[\r\n\t ]*;?/, + // "color" was removed from JSON Schema in draft-4 and is deprecated + 'color': /^(#?([0-9A-Fa-f]{3}){1,2}\b|aqua|black|blue|fuchsia|gray|green|lime|maroon|navy|olive|orange|purple|red|silver|teal|white|yellow|(rgb\(\s*\b([0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])\b\s*,\s*\b([0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])\b\s*,\s*\b([0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])\b\s*\))|(rgb\(\s*(\d?\d%|100%)+\s*,\s*(\d?\d%|100%)+\s*,\s*(\d?\d%|100%)+\s*\)))$/, + 'phone': /^\+(?:[0-9] ?){6,14}[0-9]$/, + 'alpha': /^[a-zA-Z]+$/, + 'alphanumeric': /^[a-zA-Z0-9]+$/, +}; + +FORMAT_REGEXPS.regexp = FORMAT_REGEXPS.regex; +FORMAT_REGEXPS.pattern = FORMAT_REGEXPS.regex; +FORMAT_REGEXPS.ipv4 = FORMAT_REGEXPS['ip-address']; + +#EXPORT FORMAT_REGEXPS + +function isFormat (input, format, validator) { + if (typeof input === 'string' && FORMAT_REGEXPS[format] !== undefined) { + if (FORMAT_REGEXPS[format] instanceof RegExp) { + return FORMAT_REGEXPS[format].test(input); + } + if (typeof FORMAT_REGEXPS[format] === 'function') { + return FORMAT_REGEXPS[format](input); + } + } else if (validator && validator.customFormats && + typeof validator.customFormats[format] === 'function') { + return validator.customFormats[format](input); + } + return true; +}; + +#EXPORT isFormat + +function makeSuffix (key) { + key = key.toString(); + // This function could be capable of outputting valid a ECMAScript string, but the + // resulting code for testing which form to use would be tens of thousands of characters long + // That means this will use the name form for some illegal forms + if (!key.match(/[.\s\[\]]/) && !key.match(/^[\d]/)) { + return '.' + key; + } + if (key.match(/^\d+$/)) { + return '[' + key + ']'; + } + return '[' + JSON.stringify(key) + ']'; +}; +#EXPORT makeSuffix + +function deepCompareStrict (a, b) { + if (typeof a !== typeof b) { + return false; + } + if (Array.isArray(a)) { + if (!Array.isArray(b)) { + return false; + } + if (a.length !== b.length) { + return false; + } + return a.every(function (v, i) { + return deepCompareStrict(a[i], b[i]); + }); + } + if (typeof a === 'object') { + if (!a || !b) { + return a === b; + } + var aKeys = Object.keys(a); + var bKeys = Object.keys(b); + if (aKeys.length !== bKeys.length) { + return false; + } + return aKeys.every(function (v) { + return deepCompareStrict(a[v], b[v]); + }); + } + return a === b; +}; +#EXPORT deepCompareStrict + +function deepMerger (target, dst, e, i) { + if (typeof e === 'object') { + dst[i] = deepMerge(target[i], e); + } else { + if (target.indexOf(e) === -1) { + dst.push(e); + } + } +} + +function copyist (src, dst, key) { + dst[key] = src[key]; +} + +function copyistWithDeepMerge (target, src, dst, key) { + if (typeof src[key] !== 'object' || !src[key]) { + dst[key] = src[key]; + } + else { + if (!target[key]) { + dst[key] = src[key]; + } else { + dst[key] = deepMerge(target[key], src[key]); + } + } +} + +function deepMerge (target, src) { + var array = Array.isArray(src); + var dst = array && [] || {}; + + if (array) { + target = target || []; + dst = dst.concat(target); + src.forEach(deepMerger.bind(null, target, dst)); + } else { + if (target && typeof target === 'object') { + Object.keys(target).forEach(copyist.bind(null, target, dst)); + } + Object.keys(src).forEach(copyistWithDeepMerge.bind(null, target, src, dst)); + } + + return dst; +} +#EXPORT deepMerge + +/** + * Validates instance against the provided schema + * Implements URI+JSON Pointer encoding, e.g. "%7e"="~0"=>"~", "~1"="%2f"=>"/" + * @param o + * @param s The path to walk o along + * @return any + */ +function objectGetPath(o, s) { + var parts = s.split('/').slice(1); + var k; + while (typeof (k=parts.shift()) == 'string') { + var n = decodeURIComponent(k.replace(/~0/,'~').replace(/~1/g,'/')); + if (!(n in o)) return; + o = o[n]; + } + return o; +}; +#EXPORT objectGetPath + +function pathEncoder (v) { + return '/'+encodeURIComponent(v).replace(/~/g,'%7E'); +} +/** + * Accept an Array of property names and return a JSON Pointer URI fragment + * @param Array a + * @return {String} + */ +function encodePointer(a){ + // ~ must be encoded explicitly because hacks + // the slash is encoded by encodeURIComponent + return a.map(pathEncoder).join(''); +}; +#EXPORT encodePointer AS encodePath + + +/** + * Calculate the number of decimal places a number uses + * We need this to get correct results out of multipleOf and divisibleBy + * when either figure is has decimal places, due to IEEE-754 float issues. + * @param number + * @returns {number} + */ +function getDecimalPlaces(number) { + + var decimalPlaces = 0; + if (isNaN(number)) return decimalPlaces; + + if (typeof number !== 'number') { + number = Number(number); + } + + var parts = number.toString().split('e'); + if (parts.length === 2) { + if (parts[1][0] !== '-') { + return decimalPlaces; + } else { + decimalPlaces = Number(parts[1].slice(1)); + } + } + + var decimalParts = parts[0].split('.'); + if (decimalParts.length === 2) { + decimalPlaces += decimalParts[1].length; + } + + return decimalPlaces; +}; +#EXPORT getDecimalPlaces + +function isSchema(val){ + return (typeof val === 'object' && val) || (typeof val === 'boolean'); +}; +#EXPORT isSchema diff --git a/common/jsonschema/scan.js b/common/jsonschema/scan.js new file mode 100644 index 0000000..b5e5760 --- /dev/null +++ b/common/jsonschema/scan.js @@ -0,0 +1,99 @@ +/* SPDX-License-Identifier: MIT + * + * jsonschema is licensed under MIT license. + * + * Copyright (C) 2012-2015 Tom de Grunt + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies + * of the Software, and to permit persons to whom the Software is furnished to do + * so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +#IMPORT common/jsonschema/urllib_mock.js AS urilib +#IMPORT common/jsonschema/helpers.js + +function SchemaScanResult(found, ref){ + this.id = found; + this.ref = ref; +} +#EXPORT SchemaScanResult + +/** + * Adds a schema with a certain urn to the Validator instance. + * @param string uri + * @param object schema + * @return {Object} + */ +function scan(base, schema){ + function scanSchema(baseuri, schema){ + if(!schema || typeof schema!='object') return; + // Mark all referenced schemas so we can tell later which schemas are referred to, but never defined + if(schema.$ref){ + var resolvedUri = urilib.resolve(baseuri, schema.$ref); + ref[resolvedUri] = ref[resolvedUri] ? ref[resolvedUri]+1 : 0; + return; + } + var id = schema.$id || schema.id; + var ourBase = id ? urilib.resolve(baseuri, id) : baseuri; + if (ourBase) { + // If there's no fragment, append an empty one + if(ourBase.indexOf('#')<0) ourBase += '#'; + if(found[ourBase]){ + if(!helpers.deepCompareStrict(found[ourBase], schema)){ + throw new Error('Schema <'+ourBase+'> already exists with different definition'); + } + return found[ourBase]; + } + found[ourBase] = schema; + // strip trailing fragment + if(ourBase[ourBase.length-1]=='#'){ + found[ourBase.substring(0, ourBase.length-1)] = schema; + } + } + scanArray(ourBase+'/items', (Array.isArray(schema.items)?schema.items:[schema.items])); + scanArray(ourBase+'/extends', (Array.isArray(schema.extends)?schema.extends:[schema.extends])); + scanSchema(ourBase+'/additionalItems', schema.additionalItems); + scanObject(ourBase+'/properties', schema.properties); + scanSchema(ourBase+'/additionalProperties', schema.additionalProperties); + scanObject(ourBase+'/definitions', schema.definitions); + scanObject(ourBase+'/patternProperties', schema.patternProperties); + scanObject(ourBase+'/dependencies', schema.dependencies); + scanArray(ourBase+'/disallow', schema.disallow); + scanArray(ourBase+'/allOf', schema.allOf); + scanArray(ourBase+'/anyOf', schema.anyOf); + scanArray(ourBase+'/oneOf', schema.oneOf); + scanSchema(ourBase+'/not', schema.not); + } + function scanArray(baseuri, schemas){ + if(!Array.isArray(schemas)) return; + for(var i=0; i + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the CC0 1.0 Universal License as published by + * the Creative Commons Corporation. + * + * 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 + * CC0 1.0 Universal License for more details. + */ + +/* + * The original jsonschema code used legacy resolve() function from + * require('url'). Here we define in a replacement for it. + */ + +const dummy_host = 'haketilo.resolve.example.com'; +const dummy_prefix = `http://${dummy_host}/`; + +function resolve(from, to) { + const resolvedUrl = new URL(to, new URL(from, dummy_prefix)); + if (resolvedUrl.hostname === dummy_host) { + // `from` is a relative URL. + const { pathname, search, hash } = resolvedUrl; + return pathname + search + hash; + } + return resolvedUrl.toString(); +} +#EXPORT resolve + +function parse(url_string) { + if (arguments.length > 1) + throw "error: this is a mocked version of parse() that only accepts one argument"; + + return new URL(url_string); +} +#EXPORT parse diff --git a/common/jsonschema/validator.js b/common/jsonschema/validator.js new file mode 100644 index 0000000..4d8e0cf --- /dev/null +++ b/common/jsonschema/validator.js @@ -0,0 +1,360 @@ +/* SPDX-License-Identifier: MIT + * + * jsonschema is licensed under MIT license. + * + * Copyright (C) 2012-2015 Tom de Grunt + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies + * of the Software, and to permit persons to whom the Software is furnished to do + * so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +#IMPORT common/jsonschema/urllib_mock.js AS urilib +#IMPORT common/jsonschema/attribute.js +#IMPORT common/jsonschema/helpers.js + +#FROM common/jsonschema/scan.js IMPORT scan AS scanSchema + +var ValidatorResult = helpers.ValidatorResult; +var ValidatorResultError = helpers.ValidatorResultError; +var SchemaError = helpers.SchemaError; +var SchemaContext = helpers.SchemaContext; +//var anonymousBase = 'vnd.jsonschema:///'; +var anonymousBase = '/'; + +/** + * Creates a new Validator object + * @name Validator + * @constructor + */ +var Validator = function Validator () { + // Allow a validator instance to override global custom formats or to have their + // own custom formats. + this.customFormats = Object.create(Validator.prototype.customFormats); + this.schemas = {}; + this.unresolvedRefs = []; + + // Use Object.create to make this extensible without Validator instances stepping on each other's toes. + this.types = Object.create(types); + this.attributes = Object.create(attribute.validators); +}; + +// Allow formats to be registered globally. +Validator.prototype.customFormats = {}; + +// Hint at the presence of a property +Validator.prototype.schemas = null; +Validator.prototype.types = null; +Validator.prototype.attributes = null; +Validator.prototype.unresolvedRefs = null; + +/** + * Adds a schema with a certain urn to the Validator instance. + * @param schema + * @param urn + * @return {Object} + */ +Validator.prototype.addSchema = function addSchema (schema, base) { + var self = this; + if (!schema) { + return null; + } + var scan = scanSchema(base||anonymousBase, schema); + var ourUri = base || schema.$id || schema.id; + for(var uri in scan.id){ + this.schemas[uri] = scan.id[uri]; + } + for(var uri in scan.ref){ + // If this schema is already defined, it will be filtered out by the next step + this.unresolvedRefs.push(uri); + } + // Remove newly defined schemas from unresolvedRefs + this.unresolvedRefs = this.unresolvedRefs.filter(function(uri){ + return typeof self.schemas[uri]==='undefined'; + }); + return this.schemas[ourUri]; +}; + +Validator.prototype.addSubSchemaArray = function addSubSchemaArray(baseuri, schemas) { + if(!Array.isArray(schemas)) return; + for(var i=0; i", schema); + } + var subschema = helpers.objectGetPath(ctx.schemas[document], fragment.substr(1)); + if(subschema===undefined){ + throw new SchemaError("no such schema " + fragment + " located in <" + document + ">", schema); + } + return {subschema: subschema, switchSchema: switchSchema}; +}; + +/** + * Tests whether the instance if of a certain type. + * @private + * @param instance + * @param schema + * @param options + * @param ctx + * @param type + * @return {boolean} + */ +Validator.prototype.testType = function validateType (instance, schema, options, ctx, type) { + if(type===undefined){ + return; + }else if(type===null){ + throw new SchemaError('Unexpected null in "type" keyword'); + } + if (typeof this.types[type] == 'function') { + return this.types[type].call(this, instance); + } + if (type && typeof type == 'object') { + var res = this.validateSchema(instance, type, options, ctx); + return res === undefined || !(res && res.errors.length); + } + // Undefined or properties not on the list are acceptable, same as not being defined + return true; +}; + +var types = Validator.prototype.types = {}; +types.string = function testString (instance) { + return typeof instance == 'string'; +}; +types.number = function testNumber (instance) { + // isFinite returns false for NaN, Infinity, and -Infinity + return typeof instance == 'number' && isFinite(instance); +}; +types.integer = function testInteger (instance) { + return (typeof instance == 'number') && instance % 1 === 0; +}; +types.boolean = function testBoolean (instance) { + return typeof instance == 'boolean'; +}; +types.array = function testArray (instance) { + return Array.isArray(instance); +}; +types['null'] = function testNull (instance) { + return instance === null; +}; +types.date = function testDate (instance) { + return instance instanceof Date; +}; +types.any = function testAny (instance) { + return true; +}; +types.object = function testObject (instance) { + // TODO: fix this - see #15 + return instance && (typeof instance === 'object') && !(Array.isArray(instance)) && !(instance instanceof Date); +}; + +#EXPORT Validator diff --git a/compute_scripts.awk b/compute_scripts.awk index 6235e19..bf1b08e 100755 --- a/compute_scripts.awk +++ b/compute_scripts.awk @@ -370,9 +370,6 @@ function if_condition_true(directive_args, path, function include_file(root_path, read_path, included_path, line, verbatim, read_line, result) { - if (validate_path(read_path, included_path, line)) - return 1 - if (included_path in reading) { printf "ERROR: Inclusion loop when including %s in %s\n", included_path, read_path > "/dev/stderr" diff --git a/html/install.js b/html/install.js index 95388a3..c502719 100644 --- a/html/install.js +++ b/html/install.js @@ -45,10 +45,11 @@ #IMPORT html/dialog.js #IMPORT html/item_preview.js AS ip -#FROM common/browser.js IMPORT browser -#FROM html/DOM_helpers.js IMPORT clone_template, Showable -#FROM common/entities.js IMPORT item_id_string, version_string, get_files -#FROM common/misc.js IMPORT sha256_async AS compute_sha256 +#FROM common/browser.js IMPORT browser +#FROM html/DOM_helpers.js IMPORT clone_template, Showable +#FROM common/entities.js IMPORT item_id_string, version_string, get_files +#FROM common/misc.js IMPORT sha256_async AS compute_sha256 +#FROM common/jsonschema.js IMPORT haketilo_validator, haketilo_schemas const coll = new Intl.Collator(); @@ -113,19 +114,6 @@ async function init_work() { new Promise((...cbs) => [work.resolve_cb, work.reject_cb] = cbs)]; } -function _make_url_reg(item_type) { - return new RegExp( - `^https://hydrilla\\.koszko\\.org/schemas/api_${item_type}_description-1\\.([1-9][0-9]*\\.)*schema\\.json$` - ); -} - -const _regexes = {}; - -function item_schema_url_regex(item_type) { - _regexes[item_type] = _regexes[item_type] || _make_url_reg(item_type); - return _regexes[item_type]; -} - function InstallView(tab_id, on_view_show, on_view_hide) { Showable.call(this, on_view_show, on_view_hide); @@ -204,18 +192,22 @@ function InstallView(tab_id, on_view_show, on_view_hide) { } const captype = item_type[0].toUpperCase() + item_type.substring(1); - const reg = item_schema_url_regex(item_type); - if (!response.json["$schema"]) { + const $id = + `https://hydrilla.koszko.org/schemas/api_${item_type}_description-1.0.1.schema.json`; + const schema = haketilo_schemas[$id]; + const result = haketilo_validator.validate(response.json, schema); + if (result.errors.length > 0) { + const reg = new RegExp(schema.allOf[2].properties.$schema.pattern); + if (response.json.$schema && !reg.test(response.json.$schema)) { + const msg = `${captype} ${item_id_string(id, ver)} was served using unsupported Hydrilla API version. You might need to update Haketilo.`; + return work.err(result.errors, msg); + } + const msg = `${captype} ${item_id_string(id, ver)} was served using a nonconforming response format.`; - return work.err(null, msg); - } else if (!reg.test(response.json["$schema"])) { - const msg = `${captype} ${item_id_string(id, ver)} was served using unsupported Hydrilla API version. You might need to update Haketilo.`; - return work.err(null, msg); + return work.err(result.errors, msg); } - /* TODO: JSON schema validation should be added here. */ - const scripts = item_type === "resource" && response.json.scripts; const files = response.json.source_copyright.concat(scripts || []); diff --git a/html/repo_query.js b/html/repo_query.js index d2f0e9b..61f4b10 100644 --- a/html/repo_query.js +++ b/html/repo_query.js @@ -43,10 +43,11 @@ #IMPORT common/indexeddb.js AS haketilodb -#FROM common/browser.js IMPORT browser -#FROM html/DOM_helpers.js IMPORT clone_template, Showable -#FROM common/entities.js IMPORT item_id_string, version_string -#FROM html/install.js IMPORT InstallView +#FROM common/browser.js IMPORT browser +#FROM html/DOM_helpers.js IMPORT clone_template, Showable +#FROM common/entities.js IMPORT item_id_string, version_string +#FROM html/install.js IMPORT InstallView +#FROM common/jsonschema.js IMPORT haketilo_validator, haketilo_schemas const coll = new Intl.Collator(); @@ -68,10 +69,6 @@ function ResultEntry(repo_entry, mapping_ref) { this.install_but.addEventListener("click", cb); } -const query_schema_url_regex = new RegExp( - "^https://hydrilla\\.koszko\\.org/schemas/api_query_result-1\\.([1-9][0-9]*\\.)*schema\\.json$" -); - function RepoEntry(query_view, repo_url) { Object.assign(this, clone_template("repo_query_single_repo")); Object.assign(this, {query_view, repo_url}); @@ -94,12 +91,19 @@ function RepoEntry(query_view, repo_url) { if ("error_json" in response) throw "Repository's response is not valid JSON :("; - if (!response.json["$schema"]) - throw "Results were served using a nonconforming response format."; - if (!query_schema_url_regex.test(response.json["$schema"])) - throw "Results were served using unsupported Hydrilla API version. You might need to update Haketilo."; + const $id = + `https://hydrilla.koszko.org/schemas/api_query_result-1.0.1.schema.json`; + const schema = haketilo_schemas[$id]; + const result = haketilo_validator.validate(response.json, schema); + if (result.errors.length > 0) { + console.error(result.errors); + + const reg = new RegExp(schema.properties.$schema.pattern); + if (response.json.$schema && !reg.test(response.json.$schema)) + throw "Results were served using unsupported Hydrilla API version. You might need to update Haketilo."; - /* TODO: here we should perform JSON schema validation! */ + throw "Results were served using a nonconforming response format."; + } return response.json.mappings; } diff --git a/schemas b/schemas new file mode 160000 index 0000000..09634f3 --- /dev/null +++ b/schemas @@ -0,0 +1 @@ +Subproject commit 09634f3446866f712a022327683b1149d8f46bf0 diff --git a/test/haketilo_test/unit/test_install.py b/test/haketilo_test/unit/test_install.py index f4bc483..1e2063c 100644 --- a/test/haketilo_test/unit/test_install.py +++ b/test/haketilo_test/unit/test_install.py @@ -70,18 +70,18 @@ def test_install_normal_usage(driver, execute_in_page, complex_variant): if complex_variant: # The resource/mapping others depend on. root_id = 'abcd-defg-ghij' - root_resource_id = f'resource_{root_id}' - root_mapping_id = f'mapping_{root_id}' + root_resource_id = f'resource-{root_id}' + root_mapping_id = f'mapping-{root_id}' # Those ids are used to check the alphabetical ordering. - resource_ids = [f'resource_{letters}' for letters in ( + resource_ids = [f'resource-{letters}' for letters in ( 'a', 'abcd', root_id, 'b', 'c', 'd', 'defg', 'e', 'f', 'g', 'ghij', 'h', 'i', 'j' )] files_count = 9 else: - root_resource_id = f'resource_a' - root_mapping_id = f'mapping_a' + root_resource_id = f'resource-a' + root_mapping_id = f'mapping-a' resource_ids = [root_resource_id] files_count = 0 @@ -102,7 +102,7 @@ def test_install_normal_usage(driver, execute_in_page, complex_variant): assert not execute_in_page('returnval(ets()[0].old_ver);').is_displayed() execute_in_page('returnval(ets()[0].details_but);').click() - assert 'resource_a' in containers['resource_preview_container'].text + assert 'resource-a' in containers['resource_preview_container'].text assert_container_displayed('resource_preview_container') execute_in_page('returnval(install_view.resource_back_but);').click() @@ -246,7 +246,7 @@ def test_install_dialogs(driver, execute_in_page, message): browser.tabs.sendMessage = () => new Promise(cb => {}); install_view.show(...arguments); ''', - 'https://hydril.la/', 'mapping', 'mapping_a') + 'https://hydril.la/', 'mapping', 'mapping-a') assert dlg_buts() == [] assert dialog_txt() == 'Fetching data from repository...' @@ -256,7 +256,7 @@ def test_install_dialogs(driver, execute_in_page, message): browser.tabs.sendMessage = () => Promise.resolve({error: "sth"}); install_view.show(...arguments); ''', - 'https://hydril.la/', 'mapping', 'mapping_a') + 'https://hydril.la/', 'mapping', 'mapping-a') assert_dlg(['conf_buts'], 'Failure to communicate with repository :(') elif message == 'HTTP_code_item': @@ -266,7 +266,7 @@ def test_install_dialogs(driver, execute_in_page, message): browser.tabs.sendMessage = () => Promise.resolve(response); install_view.show(...arguments); ''', - 'https://hydril.la/', 'mapping', 'mapping_a') + 'https://hydril.la/', 'mapping', 'mapping-a') assert_dlg(['conf_buts'], 'Repository sent HTTP code 404 :(') elif message == 'invalid_JSON': @@ -276,47 +276,47 @@ def test_install_dialogs(driver, execute_in_page, message): browser.tabs.sendMessage = () => Promise.resolve(response); install_view.show(...arguments); ''', - 'https://hydril.la/', 'mapping', 'mapping_a') + 'https://hydril.la/', 'mapping', 'mapping-a') assert_dlg(['conf_buts'], "Repository's response is not valid JSON :(") elif message == 'newer_API_version': execute_in_page( ''' - const response = { - ok: true, - status: 200, - json: {$schema: "https://hydrilla.koszko.org/schemas/api_mapping_description-2.1.schema.json"} - }; - browser.tabs.sendMessage = () => Promise.resolve(response); + const old_sendMessage = browser.tabs.sendMessage; + browser.tabs.sendMessage = async function(...args) { + const response = await old_sendMessage(...args); + response.json.$schema = "https://hydrilla.koszko.org/schemas/api_mapping_description-255.1.schema.json"; + return response; + } install_view.show(...arguments); ''', - 'https://hydril.la/', 'mapping', 'somemapping', [2, 1]) + 'https://hydril.la/', 'mapping', 'mapping-a', [2022, 5, 10]) assert_dlg(['conf_buts'], - 'Mapping somemapping-2.1 was served using unsupported Hydrilla API version. You might need to update Haketilo.') + 'Mapping mapping-a-2022.5.10 was served using unsupported Hydrilla API version. You might need to update Haketilo.') elif message == 'invalid_response_format': execute_in_page( ''' - const response = { - ok: true, - status: 200, - /* $schema is not a string as it should be. */ - json: {$schema: null} - }; - browser.tabs.sendMessage = () => Promise.resolve(response); + const old_sendMessage = browser.tabs.sendMessage; + browser.tabs.sendMessage = async function(...args) { + const response = await old_sendMessage(...args); + /* identifier is not a string as it should be. */ + response.json.identifier = 1234567; + return response; + } install_view.show(...arguments); ''', - 'https://hydril.la/', 'resource', 'someresource') + 'https://hydril.la/', 'resource', 'resource-a') assert_dlg(['conf_buts'], - 'Resource someresource was served using a nonconforming response format.') + 'Resource resource-a was served using a nonconforming response format.') elif message == 'indexeddb_error_item': execute_in_page( ''' haketilodb.idb_get = () => {throw "some error";}; install_view.show(...arguments); ''', - 'https://hydril.la/', 'mapping', 'mapping_a') + 'https://hydril.la/', 'mapping', 'mapping-a') assert_dlg(['conf_buts'], "Error accessing Haketilo's internal database :(") @@ -326,7 +326,7 @@ def test_install_dialogs(driver, execute_in_page, message): haketilodb.save_items = () => new Promise(() => {}); returnval(install_view.show(...arguments)); ''', - 'https://hydril.la/', 'mapping', 'mapping_b') + 'https://hydril.la/', 'mapping', 'mapping-b') execute_in_page('returnval(install_view.install_but);').click() @@ -343,7 +343,7 @@ def test_install_dialogs(driver, execute_in_page, message): } returnval(install_view.show(...arguments)); ''', - 'https://hydril.la/', 'mapping', 'mapping_b') + 'https://hydril.la/', 'mapping', 'mapping-b') execute_in_page('returnval(install_view.install_but);').click() @@ -355,7 +355,7 @@ def test_install_dialogs(driver, execute_in_page, message): fetch = () => {throw "some error";}; returnval(install_view.show(...arguments)); ''', - 'https://hydril.la/', 'mapping', 'mapping_b') + 'https://hydril.la/', 'mapping', 'mapping-b') execute_in_page('returnval(install_view.install_but);').click() @@ -367,7 +367,7 @@ def test_install_dialogs(driver, execute_in_page, message): fetch = () => Promise.resolve({ok: false, status: 400}); returnval(install_view.show(...arguments)); ''', - 'https://hydril.la/', 'mapping', 'mapping_b') + 'https://hydril.la/', 'mapping', 'mapping-b') execute_in_page('returnval(install_view.install_but);').click() @@ -379,7 +379,7 @@ def test_install_dialogs(driver, execute_in_page, message): fetch = () => Promise.resolve({ok: true, status: 200, text: err}); returnval(install_view.show(...arguments)); ''', - 'https://hydril.la/', 'mapping', 'mapping_b') + 'https://hydril.la/', 'mapping', 'mapping-b') execute_in_page('returnval(install_view.install_but);').click() @@ -396,7 +396,7 @@ def test_install_dialogs(driver, execute_in_page, message): } returnval(install_view.show(...arguments)); ''', - 'https://hydril.la/', 'mapping', 'mapping_b') + 'https://hydril.la/', 'mapping', 'mapping-b') execute_in_page('returnval(install_view.install_but);').click() @@ -413,7 +413,7 @@ def test_install_dialogs(driver, execute_in_page, message): haketilodb.save_items = () => {throw "some error";}; returnval(install_view.show(...arguments)); ''', - 'https://hydril.la/', 'mapping', 'mapping_b') + 'https://hydril.la/', 'mapping', 'mapping-b') execute_in_page('returnval(install_view.install_but);').click() diff --git a/test/haketilo_test/unit/test_popup.py b/test/haketilo_test/unit/test_popup.py index 1fc262c..e62feb7 100644 --- a/test/haketilo_test/unit/test_popup.py +++ b/test/haketilo_test/unit/test_popup.py @@ -224,7 +224,7 @@ def test_popup_repo_query(driver, execute_in_page): # Click the "Show results" button. selector = '.repo_query_buttons > button:first-child' driver.find_element_by_css_selector(selector).click() - shown = lambda d: 'MAPPING_A' in containers['repo_query'].text + shown = lambda d: 'MAPPING-A' in containers['repo_query'].text WebDriverWait(driver, 10).until(shown) # Click the "Cancel" button diff --git a/test/haketilo_test/unit/test_repo_query.py b/test/haketilo_test/unit/test_repo_query.py index c8c4875..177d415 100644 --- a/test/haketilo_test/unit/test_repo_query.py +++ b/test/haketilo_test/unit/test_repo_query.py @@ -99,7 +99,7 @@ def test_repo_query_normal_usage(driver, execute_in_page): assert len(mapping_entries) == 3 - expected_names = ['MAPPING_ABCD', 'MAPPING_ABCD-DEFG-GHIJ', 'MAPPING_A'] + expected_names = ['MAPPING-ABCD', 'MAPPING-ABCD-DEFG-GHIJ', 'MAPPING-A'] for name, entry in zip(expected_names, mapping_entries): assert entry['mapping_name'].text == name @@ -217,7 +217,7 @@ def test_repo_query_messages(driver, execute_in_page, message): const response = { ok: true, status: 200, - json: {$schema: "https://hydrilla.koszko.org/schemas/api_query_result-3.2.1.schema.json"} + json: {$schema: "https://hydrilla.koszko.org/schemas/api_query_result-255.2.1.schema.json"} }; browser.tabs.sendMessage = () => Promise.resolve(response); ''') diff --git a/test/haketilo_test/world_wide_library.py b/test/haketilo_test/world_wide_library.py index a932821..fedfeb6 100644 --- a/test/haketilo_test/world_wide_library.py +++ b/test/haketilo_test/world_wide_library.py @@ -127,14 +127,14 @@ for deps in [(0, 1, 2, 3), (3, 4, 5, 6), (6, 7, 8, 9)]: sample_resource_templates.append({ 'id_suffix': ''.join(letters), 'files_count': deps[0], - 'dependencies': [{'identifier': f'resource_{l}'} for l in letters] + 'dependencies': [{'identifier': f'resource-{l}'} for l in letters] }) suffixes = [srt['id_suffix'] for srt in sample_resource_templates] sample_resource_templates.append({ 'id_suffix': '-'.join(suffixes), 'files_count': 2, - 'dependencies': [{'identifier': f'resource_{suf}'} for suf in suffixes] + 'dependencies': [{'identifier': f'resource-{suf}'} for suf in suffixes] }) for i in range(10): @@ -150,7 +150,7 @@ sample_queries = {} for srt in sample_resource_templates: resource = make_sample_resource() - resource['identifier'] = f'resource_{srt["id_suffix"]}' + resource['identifier'] = f'resource-{srt["id_suffix"]}' resource['long_name'] = resource['identifier'].upper() resource['uuid'] = str(uuid4()) resource['dependencies'] = srt['dependencies'] @@ -164,7 +164,7 @@ for srt in sample_resource_templates: resource_versions[1][-1] += 1 mapping = make_sample_mapping() - mapping['identifier'] = f'mapping_{srt["id_suffix"]}' + mapping['identifier'] = f'mapping-{srt["id_suffix"]}' mapping['long_name'] = mapping['identifier'].upper() mapping['uuid'] = str(uuid4()) mapping['source_copyright'] = resource['source_copyright'] -- cgit v1.2.3