aboutsummaryrefslogtreecommitdiff
path: root/ext/js/data/json-schema.js
diff options
context:
space:
mode:
Diffstat (limited to 'ext/js/data/json-schema.js')
-rw-r--r--ext/js/data/json-schema.js757
1 files changed, 757 insertions, 0 deletions
diff --git a/ext/js/data/json-schema.js b/ext/js/data/json-schema.js
new file mode 100644
index 00000000..7b6b9c53
--- /dev/null
+++ b/ext/js/data/json-schema.js
@@ -0,0 +1,757 @@
+/*
+ * Copyright (C) 2019-2021 Yomichan Authors
+ *
+ * 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 <https://www.gnu.org/licenses/>.
+ */
+
+/* global
+ * CacheMap
+ */
+
+class JsonSchemaProxyHandler {
+ constructor(schema, jsonSchemaValidator) {
+ this._schema = schema;
+ this._jsonSchemaValidator = jsonSchemaValidator;
+ }
+
+ 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 = this._jsonSchemaValidator.getPropertySchema(this._schema, property, target);
+ if (propertySchema === null) {
+ return;
+ }
+
+ const value = target[property];
+ return value !== null && typeof value === 'object' ? this._jsonSchemaValidator.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 = this._jsonSchemaValidator.getPropertySchema(this._schema, property, target);
+ if (propertySchema === null) {
+ throw new Error(`Property ${property} not supported`);
+ }
+
+ value = clone(value);
+
+ this._jsonSchemaValidator.validate(value, propertySchema);
+
+ 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');
+ }
+}
+
+class JsonSchemaValidator {
+ constructor() {
+ this._regexCache = new CacheMap(100);
+ }
+
+ createProxy(target, schema) {
+ return new Proxy(target, new JsonSchemaProxyHandler(schema, this));
+ }
+
+ isValid(value, schema) {
+ try {
+ this.validate(value, schema);
+ return true;
+ } catch (e) {
+ return false;
+ }
+ }
+
+ validate(value, schema) {
+ const info = new JsonSchemaTraversalInfo(value, schema);
+ this._validate(value, schema, info);
+ }
+
+ getValidValueOrDefault(schema, value) {
+ const info = new JsonSchemaTraversalInfo(value, schema);
+ return this._getValidValueOrDefault(schema, value, info);
+ }
+
+ getPropertySchema(schema, property, value) {
+ return this._getPropertySchema(schema, property, value, null);
+ }
+
+ clearCache() {
+ this._regexCache.clear();
+ }
+
+ // Private
+
+ _getPropertySchema(schema, property, value, path) {
+ const type = this._getSchemaOrValueType(schema, value);
+ switch (type) {
+ case 'object':
+ {
+ const properties = schema.properties;
+ if (this._isObject(properties)) {
+ const propertySchema = properties[property];
+ if (this._isObject(propertySchema)) {
+ if (path !== null) { path.push(['properties', properties], [property, propertySchema]); }
+ return propertySchema;
+ }
+ }
+
+ const additionalProperties = schema.additionalProperties;
+ if (additionalProperties === false) {
+ return null;
+ } else if (this._isObject(additionalProperties)) {
+ if (path !== null) { path.push(['additionalProperties', additionalProperties]); }
+ return additionalProperties;
+ } else {
+ const result = JsonSchemaValidator.unconstrainedSchema;
+ if (path !== null) { path.push([null, result]); }
+ return result;
+ }
+ }
+ case 'array':
+ {
+ const items = schema.items;
+ if (this._isObject(items)) {
+ return items;
+ }
+ if (Array.isArray(items)) {
+ if (property >= 0 && property < items.length) {
+ const propertySchema = items[property];
+ if (this._isObject(propertySchema)) {
+ if (path !== null) { path.push(['items', items], [property, propertySchema]); }
+ return propertySchema;
+ }
+ }
+ }
+
+ const additionalItems = schema.additionalItems;
+ if (additionalItems === false) {
+ return null;
+ } else if (this._isObject(additionalItems)) {
+ if (path !== null) { path.push(['additionalItems', additionalItems]); }
+ return additionalItems;
+ } else {
+ const result = JsonSchemaValidator.unconstrainedSchema;
+ if (path !== null) { path.push([null, result]); }
+ return result;
+ }
+ }
+ default:
+ return null;
+ }
+ }
+
+ _getSchemaOrValueType(schema, value) {
+ const type = schema.type;
+
+ if (Array.isArray(type)) {
+ if (typeof value !== 'undefined') {
+ const valueType = this._getValueType(value);
+ if (type.indexOf(valueType) >= 0) {
+ return valueType;
+ }
+ }
+ return null;
+ }
+
+ if (typeof type === 'undefined') {
+ if (typeof value !== 'undefined') {
+ return this._getValueType(value);
+ }
+ return null;
+ }
+
+ return type;
+ }
+
+ _validate(value, schema, info) {
+ this._validateSingleSchema(value, schema, info);
+ this._validateConditional(value, schema, info);
+ this._validateAllOf(value, schema, info);
+ this._validateAnyOf(value, schema, info);
+ this._validateOneOf(value, schema, info);
+ this._validateNoneOf(value, schema, info);
+ }
+
+ _validateConditional(value, schema, info) {
+ const ifSchema = schema.if;
+ if (!this._isObject(ifSchema)) { return; }
+
+ let okay = true;
+ info.schemaPush('if', ifSchema);
+ try {
+ this._validate(value, ifSchema, info);
+ } catch (e) {
+ okay = false;
+ }
+ info.schemaPop();
+
+ const nextSchema = okay ? schema.then : schema.else;
+ if (this._isObject(nextSchema)) {
+ info.schemaPush(okay ? 'then' : 'else', nextSchema);
+ this._validate(value, nextSchema, info);
+ info.schemaPop();
+ }
+ }
+
+ _validateAllOf(value, schema, info) {
+ const subSchemas = schema.allOf;
+ if (!Array.isArray(subSchemas)) { return; }
+
+ info.schemaPush('allOf', subSchemas);
+ for (let i = 0; i < subSchemas.length; ++i) {
+ const subSchema = subSchemas[i];
+ info.schemaPush(i, subSchema);
+ this._validate(value, subSchema, info);
+ info.schemaPop();
+ }
+ info.schemaPop();
+ }
+
+ _validateAnyOf(value, schema, info) {
+ const subSchemas = schema.anyOf;
+ if (!Array.isArray(subSchemas)) { return; }
+
+ info.schemaPush('anyOf', subSchemas);
+ for (let i = 0; i < subSchemas.length; ++i) {
+ const subSchema = subSchemas[i];
+ info.schemaPush(i, subSchema);
+ try {
+ this._validate(value, subSchema, info);
+ return;
+ } catch (e) {
+ // NOP
+ }
+ info.schemaPop();
+ }
+
+ throw new JsonSchemaValidationError('0 anyOf schemas matched', value, schema, info);
+ // info.schemaPop(); // Unreachable
+ }
+
+ _validateOneOf(value, schema, info) {
+ const subSchemas = schema.oneOf;
+ if (!Array.isArray(subSchemas)) { return; }
+
+ info.schemaPush('oneOf', subSchemas);
+ let count = 0;
+ for (let i = 0; i < subSchemas.length; ++i) {
+ const subSchema = subSchemas[i];
+ info.schemaPush(i, subSchema);
+ try {
+ this._validate(value, subSchema, info);
+ ++count;
+ } catch (e) {
+ // NOP
+ }
+ info.schemaPop();
+ }
+
+ if (count !== 1) {
+ throw new JsonSchemaValidationError(`${count} oneOf schemas matched`, value, schema, info);
+ }
+
+ info.schemaPop();
+ }
+
+ _validateNoneOf(value, schema, info) {
+ const subSchemas = schema.not;
+ if (!Array.isArray(subSchemas)) { return; }
+
+ info.schemaPush('not', subSchemas);
+ for (let i = 0; i < subSchemas.length; ++i) {
+ const subSchema = subSchemas[i];
+ info.schemaPush(i, subSchema);
+ try {
+ this._validate(value, subSchema, info);
+ } catch (e) {
+ info.schemaPop();
+ continue;
+ }
+ throw new JsonSchemaValidationError(`not[${i}] schema matched`, value, schema, info);
+ }
+ info.schemaPop();
+ }
+
+ _validateSingleSchema(value, schema, info) {
+ const type = this._getValueType(value);
+ const schemaType = schema.type;
+ if (!this._isValueTypeAny(value, type, schemaType)) {
+ throw new JsonSchemaValidationError(`Value type ${type} does not match schema type ${schemaType}`, value, schema, info);
+ }
+
+ const schemaConst = schema.const;
+ if (typeof schemaConst !== 'undefined' && !this._valuesAreEqual(value, schemaConst)) {
+ throw new JsonSchemaValidationError('Invalid constant value', value, schema, info);
+ }
+
+ const schemaEnum = schema.enum;
+ if (Array.isArray(schemaEnum) && !this._valuesAreEqualAny(value, schemaEnum)) {
+ throw new JsonSchemaValidationError('Invalid enum value', value, schema, info);
+ }
+
+ switch (type) {
+ case 'number':
+ this._validateNumber(value, schema, info);
+ break;
+ case 'string':
+ this._validateString(value, schema, info);
+ break;
+ case 'array':
+ this._validateArray(value, schema, info);
+ break;
+ case 'object':
+ this._validateObject(value, schema, info);
+ break;
+ }
+ }
+
+ _validateNumber(value, schema, info) {
+ const multipleOf = schema.multipleOf;
+ if (typeof multipleOf === 'number' && Math.floor(value / multipleOf) * multipleOf !== value) {
+ throw new JsonSchemaValidationError(`Number is not a multiple of ${multipleOf}`, value, schema, info);
+ }
+
+ const minimum = schema.minimum;
+ if (typeof minimum === 'number' && value < minimum) {
+ throw new JsonSchemaValidationError(`Number is less than ${minimum}`, value, schema, info);
+ }
+
+ const exclusiveMinimum = schema.exclusiveMinimum;
+ if (typeof exclusiveMinimum === 'number' && value <= exclusiveMinimum) {
+ throw new JsonSchemaValidationError(`Number is less than or equal to ${exclusiveMinimum}`, value, schema, info);
+ }
+
+ const maximum = schema.maximum;
+ if (typeof maximum === 'number' && value > maximum) {
+ throw new JsonSchemaValidationError(`Number is greater than ${maximum}`, value, schema, info);
+ }
+
+ const exclusiveMaximum = schema.exclusiveMaximum;
+ if (typeof exclusiveMaximum === 'number' && value >= exclusiveMaximum) {
+ throw new JsonSchemaValidationError(`Number is greater than or equal to ${exclusiveMaximum}`, value, schema, info);
+ }
+ }
+
+ _validateString(value, schema, info) {
+ const minLength = schema.minLength;
+ if (typeof minLength === 'number' && value.length < minLength) {
+ throw new JsonSchemaValidationError('String length too short', value, schema, info);
+ }
+
+ const maxLength = schema.maxLength;
+ if (typeof maxLength === 'number' && value.length > maxLength) {
+ throw new JsonSchemaValidationError('String length too long', value, schema, info);
+ }
+
+ const pattern = schema.pattern;
+ if (typeof pattern === 'string') {
+ let patternFlags = schema.patternFlags;
+ if (typeof patternFlags !== 'string') { patternFlags = ''; }
+
+ let regex;
+ try {
+ regex = this._getRegex(pattern, patternFlags);
+ } catch (e) {
+ throw new JsonSchemaValidationError(`Pattern is invalid (${e.message})`, value, schema, info);
+ }
+
+ if (!regex.test(value)) {
+ throw new JsonSchemaValidationError('Pattern match failed', value, schema, info);
+ }
+ }
+ }
+
+ _validateArray(value, schema, info) {
+ const minItems = schema.minItems;
+ if (typeof minItems === 'number' && value.length < minItems) {
+ throw new JsonSchemaValidationError('Array length too short', value, schema, info);
+ }
+
+ const maxItems = schema.maxItems;
+ if (typeof maxItems === 'number' && value.length > maxItems) {
+ throw new JsonSchemaValidationError('Array length too long', value, schema, info);
+ }
+
+ this._validateArrayContains(value, schema, info);
+
+ for (let i = 0, ii = value.length; i < ii; ++i) {
+ const schemaPath = [];
+ const propertySchema = this._getPropertySchema(schema, i, value, schemaPath);
+ if (propertySchema === null) {
+ throw new JsonSchemaValidationError(`No schema found for array[${i}]`, value, schema, info);
+ }
+
+ const propertyValue = value[i];
+
+ for (const [p, s] of schemaPath) { info.schemaPush(p, s); }
+ info.valuePush(i, propertyValue);
+ this._validate(propertyValue, propertySchema, info);
+ info.valuePop();
+ for (let j = 0, jj = schemaPath.length; j < jj; ++j) { info.schemaPop(); }
+ }
+ }
+
+ _validateArrayContains(value, schema, info) {
+ const containsSchema = schema.contains;
+ if (!this._isObject(containsSchema)) { return; }
+
+ info.schemaPush('contains', containsSchema);
+ for (let i = 0, ii = value.length; i < ii; ++i) {
+ const propertyValue = value[i];
+ info.valuePush(i, propertyValue);
+ try {
+ this._validate(propertyValue, containsSchema, info);
+ info.schemaPop();
+ return;
+ } catch (e) {
+ // NOP
+ }
+ info.valuePop();
+ }
+ throw new JsonSchemaValidationError('contains schema didn\'t match', value, schema, info);
+ }
+
+ _validateObject(value, schema, info) {
+ const properties = new Set(Object.getOwnPropertyNames(value));
+
+ const required = schema.required;
+ if (Array.isArray(required)) {
+ for (const property of required) {
+ if (!properties.has(property)) {
+ throw new JsonSchemaValidationError(`Missing property ${property}`, value, schema, info);
+ }
+ }
+ }
+
+ const minProperties = schema.minProperties;
+ if (typeof minProperties === 'number' && properties.length < minProperties) {
+ throw new JsonSchemaValidationError('Not enough object properties', value, schema, info);
+ }
+
+ const maxProperties = schema.maxProperties;
+ if (typeof maxProperties === 'number' && properties.length > maxProperties) {
+ throw new JsonSchemaValidationError('Too many object properties', value, schema, info);
+ }
+
+ for (const property of properties) {
+ const schemaPath = [];
+ const propertySchema = this._getPropertySchema(schema, property, value, schemaPath);
+ if (propertySchema === null) {
+ throw new JsonSchemaValidationError(`No schema found for ${property}`, value, schema, info);
+ }
+
+ const propertyValue = value[property];
+
+ for (const [p, s] of schemaPath) { info.schemaPush(p, s); }
+ info.valuePush(property, propertyValue);
+ this._validate(propertyValue, propertySchema, info);
+ info.valuePop();
+ for (let i = 0; i < schemaPath.length; ++i) { info.schemaPop(); }
+ }
+ }
+
+ _isValueTypeAny(value, type, schemaTypes) {
+ if (typeof schemaTypes === 'string') {
+ return this._isValueType(value, type, schemaTypes);
+ } else if (Array.isArray(schemaTypes)) {
+ for (const schemaType of schemaTypes) {
+ if (this._isValueType(value, type, schemaType)) {
+ return true;
+ }
+ }
+ return false;
+ }
+ return true;
+ }
+
+ _isValueType(value, type, schemaType) {
+ return (
+ type === schemaType ||
+ (schemaType === 'integer' && Math.floor(value) === value)
+ );
+ }
+
+ _getValueType(value) {
+ const type = typeof value;
+ if (type === 'object') {
+ if (value === null) { return 'null'; }
+ if (Array.isArray(value)) { return 'array'; }
+ }
+ return type;
+ }
+
+ _valuesAreEqualAny(value1, valueList) {
+ for (const value2 of valueList) {
+ if (this._valuesAreEqual(value1, value2)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ _valuesAreEqual(value1, value2) {
+ return value1 === value2;
+ }
+
+ _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;
+ }
+
+ _getDefaultSchemaValue(schema) {
+ const schemaType = schema.type;
+ const schemaDefault = schema.default;
+ return (
+ typeof schemaDefault !== 'undefined' &&
+ this._isValueTypeAny(schemaDefault, this._getValueType(schemaDefault), schemaType) ?
+ clone(schemaDefault) :
+ this._getDefaultTypeValue(schemaType)
+ );
+ }
+
+ _getValidValueOrDefault(schema, value, info) {
+ let type = this._getValueType(value);
+ if (typeof value === 'undefined' || !this._isValueTypeAny(value, type, schema.type)) {
+ value = this._getDefaultSchemaValue(schema);
+ type = this._getValueType(value);
+ }
+
+ switch (type) {
+ case 'object':
+ value = this._populateObjectDefaults(value, schema, info);
+ break;
+ case 'array':
+ value = this._populateArrayDefaults(value, schema, info);
+ break;
+ default:
+ if (!this.isValid(value, schema)) {
+ const schemaDefault = this._getDefaultSchemaValue(schema);
+ if (this.isValid(schemaDefault, schema)) {
+ value = schemaDefault;
+ }
+ }
+ break;
+ }
+
+ return value;
+ }
+
+ _populateObjectDefaults(value, schema, info) {
+ 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 = this._getPropertySchema(schema, property, value, null);
+ if (propertySchema === null) { continue; }
+ info.valuePush(property, value);
+ info.schemaPush(property, propertySchema);
+ const hasValue = Object.prototype.hasOwnProperty.call(value, property);
+ value[property] = this._getValidValueOrDefault(propertySchema, hasValue ? value[property] : void 0, info);
+ info.schemaPop();
+ info.valuePop();
+ }
+ }
+
+ for (const property of properties) {
+ const propertySchema = this._getPropertySchema(schema, property, value, null);
+ if (propertySchema === null) {
+ Reflect.deleteProperty(value, property);
+ } else {
+ info.valuePush(property, value);
+ info.schemaPush(property, propertySchema);
+ value[property] = this._getValidValueOrDefault(propertySchema, value[property], info);
+ info.schemaPop();
+ info.valuePop();
+ }
+ }
+
+ return value;
+ }
+
+ _populateArrayDefaults(value, schema, info) {
+ for (let i = 0, ii = value.length; i < ii; ++i) {
+ const propertySchema = this._getPropertySchema(schema, i, value, null);
+ if (propertySchema === null) { continue; }
+ info.valuePush(i, value);
+ info.schemaPush(i, propertySchema);
+ value[i] = this._getValidValueOrDefault(propertySchema, value[i], info);
+ info.schemaPop();
+ info.valuePop();
+ }
+
+ const minItems = schema.minItems;
+ if (typeof minItems === 'number' && value.length < minItems) {
+ for (let i = value.length; i < minItems; ++i) {
+ const propertySchema = this._getPropertySchema(schema, i, value, null);
+ if (propertySchema === null) { break; }
+ info.valuePush(i, value);
+ info.schemaPush(i, propertySchema);
+ const item = this._getValidValueOrDefault(propertySchema, void 0, info);
+ info.schemaPop();
+ info.valuePop();
+ value.push(item);
+ }
+ }
+
+ const maxItems = schema.maxItems;
+ if (typeof maxItems === 'number' && value.length > maxItems) {
+ value.splice(maxItems, value.length - maxItems);
+ }
+
+ return value;
+ }
+
+ _isObject(value) {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+ }
+
+ _getRegex(pattern, flags) {
+ const key = `${flags}:${pattern}`;
+ let regex = this._regexCache.get(key);
+ if (typeof regex === 'undefined') {
+ regex = new RegExp(pattern, flags);
+ this._regexCache.set(key, regex);
+ }
+ return regex;
+ }
+}
+
+Object.defineProperty(JsonSchemaValidator, 'unconstrainedSchema', {
+ value: Object.freeze({}),
+ configurable: false,
+ enumerable: true,
+ writable: false
+});
+
+class JsonSchemaTraversalInfo {
+ constructor(value, schema) {
+ this.valuePath = [];
+ this.schemaPath = [];
+ this.valuePush(null, value);
+ this.schemaPush(null, schema);
+ }
+
+ valuePush(path, value) {
+ this.valuePath.push([path, value]);
+ }
+
+ valuePop() {
+ this.valuePath.pop();
+ }
+
+ schemaPush(path, schema) {
+ this.schemaPath.push([path, schema]);
+ }
+
+ schemaPop() {
+ this.schemaPath.pop();
+ }
+}
+
+class JsonSchemaValidationError extends Error {
+ constructor(message, value, schema, info) {
+ super(message);
+ this.value = value;
+ this.schema = schema;
+ this.info = info;
+ }
+}