diff options
Diffstat (limited to 'ext/js/data/json-schema.js')
-rw-r--r-- | ext/js/data/json-schema.js | 927 |
1 files changed, 652 insertions, 275 deletions
diff --git a/ext/js/data/json-schema.js b/ext/js/data/json-schema.js index 93c8cd59..d63cfd1a 100644 --- a/ext/js/data/json-schema.js +++ b/ext/js/data/json-schema.js @@ -19,31 +19,70 @@ import {clone} from '../core.js'; import {CacheMap} from '../general/cache-map.js'; +export class JsonSchemaError extends Error { + /** + * @param {string} message + * @param {import('json-schema').ValueStackItem[]} valueStack + * @param {import('json-schema').SchemaStackItem[]} schemaStack + */ + constructor(message, valueStack, schemaStack) { + super(message); + /** @type {import('json-schema').ValueStackItem[]} */ + this._valueStack = valueStack; + /** @type {import('json-schema').SchemaStackItem[]} */ + this._schemaStack = schemaStack; + } + + /** @type {unknown|undefined} */ + get value() { return this._valueStack.length > 0 ? this._valueStack[this._valueStack.length - 1].value : void 0; } + + /** @type {import('json-schema').Schema|import('json-schema').Schema[]|undefined} */ + get schema() { return this._schemaStack.length > 0 ? this._schemaStack[this._schemaStack.length - 1].schema : void 0; } + + /** @type {import('json-schema').ValueStackItem[]} */ + get valueStack() { return this._valueStack; } + + /** @type {import('json-schema').SchemaStackItem[]} */ + get schemaStack() { return this._schemaStack; } +} + export class JsonSchema { + /** + * @param {import('json-schema').Schema} schema + * @param {import('json-schema').Schema} [rootSchema] + */ constructor(schema, rootSchema) { - this._schema = null; + /** @type {import('json-schema').Schema} */ this._startSchema = schema; + /** @type {import('json-schema').Schema} */ this._rootSchema = typeof rootSchema !== 'undefined' ? rootSchema : schema; + /** @type {?CacheMap<string, RegExp>} */ this._regexCache = null; + /** @type {?Map<string, {schema: import('json-schema').Schema, stack: import('json-schema').SchemaStackItem[]}>} */ this._refCache = null; + /** @type {import('json-schema').ValueStackItem[]} */ this._valueStack = []; + /** @type {import('json-schema').SchemaStackItem[]} */ this._schemaStack = []; + /** @type {?(jsonSchema: JsonSchema) => void} */ this._progress = null; + /** @type {number} */ this._progressCounter = 0; + /** @type {number} */ this._progressInterval = 1; - - this._schemaPush(null, null); - this._valuePush(null, null); } + /** @type {import('json-schema').Schema} */ get schema() { return this._startSchema; } + /** @type {import('json-schema').Schema} */ get rootSchema() { return this._rootSchema; } + /** @type {?(jsonSchema: JsonSchema) => void} */ get progress() { return this._progress; } @@ -52,6 +91,7 @@ export class JsonSchema { this._progress = value; } + /** @type {number} */ get progressInterval() { return this._progressInterval; } @@ -60,6 +100,10 @@ export class JsonSchema { this._progressInterval = value; } + /** + * @param {import('json-schema').Value} value + * @returns {import('json-schema').Value} + */ createProxy(value) { return ( typeof value === 'object' && value !== null ? @@ -68,6 +112,10 @@ export class JsonSchema { ); } + /** + * @param {unknown} value + * @returns {boolean} + */ isValid(value) { try { this.validate(value); @@ -77,123 +125,203 @@ export class JsonSchema { } } + /** + * @param {unknown} value + */ validate(value) { - this._schemaPush(this._startSchema, null); + const schema = this._startSchema; + this._schemaPush(schema, null); this._valuePush(value, null); try { - this._validate(value); + this._validate(schema, value); } finally { this._valuePop(); this._schemaPop(); } } + /** + * @param {unknown} [value] + * @returns {import('json-schema').Value} + */ getValidValueOrDefault(value) { - return this._getValidValueOrDefault(null, value, {schema: this._startSchema, path: null}); + const schema = this._startSchema; + return this._getValidValueOrDefault(schema, null, value, [{schema, path: null}]); } + /** + * @param {string} property + * @returns {?JsonSchema} + */ getObjectPropertySchema(property) { - const startSchemaInfo = this._getResolveSchemaInfo({schema: this._startSchema, path: null}); - this._schemaPush(startSchemaInfo.schema, startSchemaInfo.path); + const schema = this._startSchema; + const {schema: schema2, stack} = this._getResolvedSchemaInfo(schema, [{schema, path: null}]); + this._schemaPushMultiple(stack); try { - const schemaInfo = this._getObjectPropertySchemaInfo(property); - return schemaInfo !== null ? new JsonSchema(schemaInfo.schema, this._rootSchema) : null; + const {schema: propertySchema} = this._getObjectPropertySchemaInfo(schema2, property); + return propertySchema !== false ? new JsonSchema(propertySchema, this._rootSchema) : null; } finally { - this._schemaPop(); + this._schemaPopMultiple(stack.length); } } + /** + * @param {number} index + * @returns {?JsonSchema} + */ getArrayItemSchema(index) { - const startSchemaInfo = this._getResolveSchemaInfo({schema: this._startSchema, path: null}); - this._schemaPush(startSchemaInfo.schema, startSchemaInfo.path); + const schema = this._startSchema; + const {schema: schema2, stack} = this._getResolvedSchemaInfo(schema, [{schema, path: null}]); + this._schemaPushMultiple(stack); try { - const schemaInfo = this._getArrayItemSchemaInfo(index); - return schemaInfo !== null ? new JsonSchema(schemaInfo.schema, this._rootSchema) : null; + const {schema: itemSchema} = this._getArrayItemSchemaInfo(schema2, index); + return itemSchema !== false ? new JsonSchema(itemSchema, this._rootSchema) : null; } finally { - this._schemaPop(); + this._schemaPopMultiple(stack.length); } } + /** + * @param {string} property + * @returns {boolean} + */ isObjectPropertyRequired(property) { - const {required} = this._startSchema; + const schema = this._startSchema; + if (typeof schema === 'boolean') { return false; } + const {required} = schema; return Array.isArray(required) && required.includes(property); } // Internal state functions for error construction and progress callback + /** + * @returns {import('json-schema').ValueStackItem[]} + */ getValueStack() { - const valueStack = []; - for (let i = 1, ii = this._valueStack.length; i < ii; ++i) { - const {value, path} = this._valueStack[i]; - valueStack.push({value, path}); + const result = []; + for (const {value, path} of this._valueStack) { + result.push({value, path}); } - return valueStack; + return result; } + /** + * @returns {import('json-schema').SchemaStackItem[]} + */ getSchemaStack() { - const schemaStack = []; - for (let i = 1, ii = this._schemaStack.length; i < ii; ++i) { - const {schema, path} = this._schemaStack[i]; - schemaStack.push({schema, path}); + const result = []; + for (const {schema, path} of this._schemaStack) { + result.push({schema, path}); } - return schemaStack; + return result; } + /** + * @returns {number} + */ getValueStackLength() { return this._valueStack.length - 1; } + /** + * @param {number} index + * @returns {import('json-schema').ValueStackItem} + */ getValueStackItem(index) { const {value, path} = this._valueStack[index + 1]; return {value, path}; } + /** + * @returns {number} + */ getSchemaStackLength() { return this._schemaStack.length - 1; } + /** + * @param {number} index + * @returns {import('json-schema').SchemaStackItem} + */ getSchemaStackItem(index) { const {schema, path} = this._schemaStack[index + 1]; return {schema, path}; } + /** + * @template T + * @param {T} value + * @returns {T} + */ + static clone(value) { + return clone(value); + } + // Stack + /** + * @param {unknown} value + * @param {string|number|null} path + */ _valuePush(value, path) { this._valueStack.push({value, path}); } + /** + * @returns {void} + */ _valuePop() { this._valueStack.pop(); } + /** + * @param {import('json-schema').Schema|import('json-schema').Schema[]} schema + * @param {string|number|null} path + */ _schemaPush(schema, path) { this._schemaStack.push({schema, path}); - this._schema = schema; } + /** + * @param {import('json-schema').SchemaStackItem[]} items + */ + _schemaPushMultiple(items) { + this._schemaStack.push(...items); + } + + /** + * @returns {void} + */ _schemaPop() { this._schemaStack.pop(); - this._schema = this._schemaStack[this._schemaStack.length - 1].schema; + } + + /** + * @param {number} count + */ + _schemaPopMultiple(count) { + for (let i = 0; i < count; ++i) { + this._schemaStack.pop(); + } } // Private + /** + * @param {string} message + * @returns {JsonSchemaError} + */ _createError(message) { const valueStack = this.getValueStack(); const schemaStack = this.getSchemaStack(); - const error = new Error(message); - error.value = valueStack[valueStack.length - 1].value; - error.schema = schemaStack[schemaStack.length - 1].schema; - error.valueStack = valueStack; - error.schemaStack = schemaStack; - return error; - } - - _isObject(value) { - return typeof value === 'object' && value !== null && !Array.isArray(value); + return new JsonSchemaError(message, valueStack, schemaStack); } + /** + * @param {string} pattern + * @param {string} flags + * @returns {RegExp} + */ _getRegex(pattern, flags) { if (this._regexCache === null) { this._regexCache = new CacheMap(100); @@ -208,81 +336,125 @@ export class JsonSchema { return regex; } - _getUnconstrainedSchema() { - return {}; - } - - _getObjectPropertySchemaInfo(property) { - const {properties} = this._schema; - if (this._isObject(properties)) { + /** + * @param {import('json-schema').Schema} schema + * @param {string} property + * @returns {{schema: import('json-schema').Schema, stack: import('json-schema').SchemaStackItem[]}} + */ + _getObjectPropertySchemaInfo(schema, property) { + if (typeof schema === 'boolean') { + return {schema, stack: [{schema, path: null}]}; + } + const {properties} = schema; + if (typeof properties !== 'undefined' && Object.prototype.hasOwnProperty.call(properties, property)) { const propertySchema = properties[property]; - if (this._isObject(propertySchema)) { - return {schema: propertySchema, path: ['properties', property]}; + if (typeof propertySchema !== 'undefined') { + return { + schema: propertySchema, + stack: [ + {schema: properties, path: 'properties'}, + {schema: propertySchema, path: property} + ] + }; } } - - const {additionalProperties} = this._schema; - if (additionalProperties === false) { - return null; - } else if (this._isObject(additionalProperties)) { - return {schema: additionalProperties, path: 'additionalProperties'}; - } else { - const result = this._getUnconstrainedSchema(); - return {schema: result, path: null}; - } - } - - _getArrayItemSchemaInfo(index) { - const {items} = this._schema; - if (this._isObject(items)) { - return {schema: items, path: 'items'}; - } - if (Array.isArray(items)) { - if (index >= 0 && index < items.length) { - const propertySchema = items[index]; - if (this._isObject(propertySchema)) { - return {schema: propertySchema, path: ['items', index]}; + return this._getOptionalSchemaInfo(schema.additionalProperties, 'additionalProperties'); + } + + /** + * @param {import('json-schema').Schema} schema + * @param {number} index + * @returns {{schema: import('json-schema').Schema, stack: import('json-schema').SchemaStackItem[]}} + */ + _getArrayItemSchemaInfo(schema, index) { + if (typeof schema === 'boolean') { + return {schema, stack: [{schema, path: null}]}; + } + const {prefixItems} = schema; + if (typeof prefixItems !== 'undefined') { + if (index >= 0 && index < prefixItems.length) { + const itemSchema = prefixItems[index]; + if (typeof itemSchema !== 'undefined') { + return { + schema: itemSchema, + stack: [ + {schema: prefixItems, path: 'prefixItems'}, + {schema: itemSchema, path: index} + ] + }; } } } - - const {additionalItems} = this._schema; - if (additionalItems === false) { - return null; - } else if (this._isObject(additionalItems)) { - return {schema: additionalItems, path: 'additionalItems'}; - } else { - const result = this._getUnconstrainedSchema(); - return {schema: result, path: null}; - } - } - - _getSchemaOrValueType(value) { - const {type} = this._schema; - - if (Array.isArray(type)) { - if (typeof value !== 'undefined') { - const valueType = this._getValueType(value); - if (type.indexOf(valueType) >= 0) { - return valueType; + const {items} = schema; + if (typeof items !== 'undefined') { + if (Array.isArray(items)) { // Legacy schema format + if (index >= 0 && index < items.length) { + const itemSchema = items[index]; + if (typeof itemSchema !== 'undefined') { + return { + schema: itemSchema, + stack: [ + {schema: items, path: 'items'}, + {schema: itemSchema, path: index} + ] + }; + } } + } else { + return { + schema: items, + stack: [{schema: items, path: 'items'}] + }; } - return null; } + return this._getOptionalSchemaInfo(schema.additionalItems, 'additionalItems'); + } - if (typeof type !== 'undefined') { return type; } - return (typeof value !== 'undefined') ? this._getValueType(value) : null; + /** + * @param {import('json-schema').Schema|undefined} schema + * @param {string|number|null} path + * @returns {{schema: import('json-schema').Schema, stack: import('json-schema').SchemaStackItem[]}} + */ + _getOptionalSchemaInfo(schema, path) { + switch (typeof schema) { + case 'boolean': + case 'object': + break; + default: + schema = true; + path = null; + break; + } + return {schema, stack: [{schema, path}]}; } + /** + * @param {unknown} value + * @returns {?import('json-schema').Type} + * @throws {Error} + */ _getValueType(value) { const type = typeof value; - if (type === 'object') { - if (value === null) { return 'null'; } - if (Array.isArray(value)) { return 'array'; } + switch (type) { + case 'object': + if (value === null) { return 'null'; } + if (Array.isArray(value)) { return 'array'; } + return 'object'; + case 'string': + case 'number': + case 'boolean': + return type; + default: + return null; } - return type; } + /** + * @param {unknown} value + * @param {?import('json-schema').Type} type + * @param {import('json-schema').Type|import('json-schema').Type[]|undefined} schemaTypes + * @returns {boolean} + */ _isValueTypeAny(value, type, schemaTypes) { if (typeof schemaTypes === 'string') { return this._isValueType(value, type, schemaTypes); @@ -297,13 +469,24 @@ export class JsonSchema { return true; } + /** + * @param {unknown} value + * @param {?import('json-schema').Type} type + * @param {import('json-schema').Type} schemaType + * @returns {boolean} + */ _isValueType(value, type, schemaType) { return ( type === schemaType || - (schemaType === 'integer' && Math.floor(value) === value) + (schemaType === 'integer' && typeof value === 'number' && Math.floor(value) === value) ); } + /** + * @param {unknown} value1 + * @param {import('json-schema').Value[]} valueList + * @returns {boolean} + */ _valuesAreEqualAny(value1, valueList) { for (const value2 of valueList) { if (this._valuesAreEqual(value1, value2)) { @@ -313,29 +496,45 @@ export class JsonSchema { return false; } + /** + * @param {unknown} value1 + * @param {import('json-schema').Value} value2 + * @returns {boolean} + */ _valuesAreEqual(value1, value2) { return value1 === value2; } - _getResolveSchemaInfo(schemaInfo) { - const ref = schemaInfo.schema.$ref; - if (typeof ref !== 'string') { return schemaInfo; } - - const {path: basePath} = schemaInfo; - const {schema, path} = this._getReference(ref); - if (Array.isArray(basePath)) { - path.unshift(...basePath); - } else { - path.unshift(basePath); + /** + * @param {import('json-schema').Schema} schema + * @param {import('json-schema').SchemaStackItem[]} stack + * @returns {{schema: import('json-schema').Schema, stack: import('json-schema').SchemaStackItem[]}} + */ + _getResolvedSchemaInfo(schema, stack) { + if (typeof schema !== 'boolean') { + const ref = schema.$ref; + if (typeof ref === 'string') { + const {schema: schema2, stack: stack2} = this._getReference(ref); + return { + schema: schema2, + stack: [...stack, ...stack2] + }; + } } - return {schema, path}; + return {schema, stack}; } + /** + * @param {string} ref + * @returns {{schema: import('json-schema').Schema, stack: import('json-schema').SchemaStackItem[]}} + * @throws {Error} + */ _getReference(ref) { if (!ref.startsWith('#/')) { throw this._createError(`Unsupported reference path: ${ref}`); } + /** @type {{schema: import('json-schema').Schema, stack: import('json-schema').SchemaStackItem[]}|undefined} */ let info; if (this._refCache !== null) { info = this._refCache.get(ref); @@ -348,12 +547,20 @@ export class JsonSchema { this._refCache.set(ref, info); } - return {schema: info.schema, path: [...info.path]}; + info.stack = this._copySchemaStack(info.stack); + return info; } + /** + * @param {string} ref + * @returns {{schema: import('json-schema').Schema, stack: import('json-schema').SchemaStackItem[]}} + * @throws {Error} + */ _getReferenceUncached(ref) { + /** @type {Set<string>} */ const visited = new Set(); - const path = []; + /** @type {import('json-schema').SchemaStackItem[]} */ + const stack = []; while (true) { if (visited.has(ref)) { throw this._createError(`Recursive reference: ${ref}`); @@ -362,106 +569,139 @@ export class JsonSchema { const pathParts = ref.substring(2).split('/'); let schema = this._rootSchema; - try { - for (const pathPart of pathParts) { - schema = schema[pathPart]; + stack.push({schema, path: null}); + for (const pathPart of pathParts) { + if (!(typeof schema === 'object' && schema !== null && Object.prototype.hasOwnProperty.call(schema, pathPart))) { + throw this._createError(`Invalid reference: ${ref}`); } - } catch (e) { - throw this._createError(`Invalid reference: ${ref}`); + const schemaNext = /** @type {import('core').UnknownObject} */ (schema)[pathPart]; + if (!(typeof schemaNext === 'boolean' || (typeof schemaNext === 'object' && schemaNext !== null))) { + throw this._createError(`Invalid reference: ${ref}`); + } + schema = schemaNext; + stack.push({schema, path: pathPart}); } - if (!this._isObject(schema)) { + if (Array.isArray(schema)) { throw this._createError(`Invalid reference: ${ref}`); } - path.push(null, ...pathParts); - - ref = schema.$ref; - if (typeof ref !== 'string') { - return {schema, path}; + const refNext = typeof schema === 'object' && schema !== null ? schema.$ref : void 0; + if (typeof refNext !== 'string') { + return {schema, stack}; } + ref = refNext; + } + } + + /** + * @param {import('json-schema').SchemaStackItem[]} schemaStack + * @returns {import('json-schema').SchemaStackItem[]} + */ + _copySchemaStack(schemaStack) { + /** @type {import('json-schema').SchemaStackItem[]} */ + const results = []; + for (const {schema, path} of schemaStack) { + results.push({schema, path}); } + return results; } // Validation - _isValidCurrent(value) { + /** + * @param {import('json-schema').SchemaObject} schema + * @param {unknown} value + * @returns {boolean} + */ + _isValidCurrent(schema, value) { try { - this._validate(value); + this._validate(schema, value); return true; } catch (e) { return false; } } - _validate(value) { + /** + * @param {import('json-schema').Schema} schema + * @param {unknown} value + */ + _validate(schema, value) { if (this._progress !== null) { const counter = (this._progressCounter + 1) % this._progressInterval; this._progressCounter = counter; if (counter === 0) { this._progress(this); } } - const ref = this._schema.$ref; - const schemaInfo = (typeof ref === 'string') ? this._getReference(ref) : null; - - if (schemaInfo === null) { - this._validateInner(value); - } else { - this._schemaPush(schemaInfo.schema, schemaInfo.path); - try { - this._validateInner(value); - } finally { - this._schemaPop(); - } - } - } - - _validateInner(value) { - this._validateSingleSchema(value); - this._validateConditional(value); - this._validateAllOf(value); - this._validateAnyOf(value); - this._validateOneOf(value); - this._validateNoneOf(value); - } - - _validateConditional(value) { - const ifSchema = this._schema.if; - if (!this._isObject(ifSchema)) { return; } + const {schema: schema2, stack} = this._getResolvedSchemaInfo(schema, []); + this._schemaPushMultiple(stack); + try { + this._validateInner(schema2, value); + } finally { + this._schemaPopMultiple(stack.length); + } + } + + /** + * @param {import('json-schema').Schema} schema + * @param {unknown} value + * @throws {Error} + */ + _validateInner(schema, value) { + if (schema === true) { return; } + if (schema === false) { throw this._createError('False schema'); } + this._validateSingleSchema(schema, value); + this._validateConditional(schema, value); + this._validateAllOf(schema, value); + this._validateAnyOf(schema, value); + this._validateOneOf(schema, value); + this._validateNot(schema, value); + } + + /** + * @param {import('json-schema').SchemaObject} schema + * @param {unknown} value + */ + _validateConditional(schema, value) { + const ifSchema = schema.if; + if (typeof ifSchema === 'undefined') { return; } let okay = true; this._schemaPush(ifSchema, 'if'); try { - this._validate(value); + this._validate(ifSchema, value); } catch (e) { okay = false; } finally { this._schemaPop(); } - const nextSchema = okay ? this._schema.then : this._schema.else; - if (this._isObject(nextSchema)) { return; } + const nextSchema = okay ? schema.then : schema.else; + if (typeof nextSchema === 'undefined') { return; } this._schemaPush(nextSchema, okay ? 'then' : 'else'); try { - this._validate(value); + this._validate(nextSchema, value); } finally { this._schemaPop(); } } - _validateAllOf(value) { - const subSchemas = this._schema.allOf; + /** + * @param {import('json-schema').SchemaObject} schema + * @param {unknown} value + */ + _validateAllOf(schema, value) { + const subSchemas = schema.allOf; if (!Array.isArray(subSchemas)) { return; } this._schemaPush(subSchemas, 'allOf'); try { for (let i = 0, ii = subSchemas.length; i < ii; ++i) { const subSchema = subSchemas[i]; - if (!this._isObject(subSchema)) { continue; } - this._schemaPush(subSchema, i); try { - this._validate(value); + this._validate(subSchema, value); } finally { this._schemaPop(); } @@ -471,19 +711,21 @@ export class JsonSchema { } } - _validateAnyOf(value) { - const subSchemas = this._schema.anyOf; + /** + * @param {import('json-schema').SchemaObject} schema + * @param {unknown} value + */ + _validateAnyOf(schema, value) { + const subSchemas = schema.anyOf; if (!Array.isArray(subSchemas)) { return; } this._schemaPush(subSchemas, 'anyOf'); try { for (let i = 0, ii = subSchemas.length; i < ii; ++i) { const subSchema = subSchemas[i]; - if (!this._isObject(subSchema)) { continue; } - this._schemaPush(subSchema, i); try { - this._validate(value); + this._validate(subSchema, value); return; } catch (e) { // NOP @@ -498,8 +740,12 @@ export class JsonSchema { } } - _validateOneOf(value) { - const subSchemas = this._schema.oneOf; + /** + * @param {import('json-schema').SchemaObject} schema + * @param {unknown} value + */ + _validateOneOf(schema, value) { + const subSchemas = schema.oneOf; if (!Array.isArray(subSchemas)) { return; } this._schemaPush(subSchemas, 'oneOf'); @@ -507,11 +753,9 @@ export class JsonSchema { let count = 0; for (let i = 0, ii = subSchemas.length; i < ii; ++i) { const subSchema = subSchemas[i]; - if (!this._isObject(subSchema)) { continue; } - this._schemaPush(subSchema, i); try { - this._validate(value); + this._validate(subSchema, value); ++count; } catch (e) { // NOP @@ -528,33 +772,37 @@ export class JsonSchema { } } - _validateNoneOf(value) { - const subSchemas = this._schema.not; - if (!Array.isArray(subSchemas)) { return; } + /** + * @param {import('json-schema').SchemaObject} schema + * @param {unknown} value + * @throws {Error} + */ + _validateNot(schema, value) { + const notSchema = schema.not; + if (typeof notSchema === 'undefined') { return; } - this._schemaPush(subSchemas, 'not'); - try { - for (let i = 0, ii = subSchemas.length; i < ii; ++i) { - const subSchema = subSchemas[i]; - if (!this._isObject(subSchema)) { continue; } + if (Array.isArray(notSchema)) { + throw this._createError('not schema is an array'); + } - this._schemaPush(subSchema, i); - try { - this._validate(value); - } catch (e) { - continue; - } finally { - this._schemaPop(); - } - throw this._createError(`not[${i}] schema matched`); - } + this._schemaPush(notSchema, 'not'); + try { + this._validate(notSchema, value); + } catch (e) { + return; } finally { this._schemaPop(); } + throw this._createError('not schema matched'); } - _validateSingleSchema(value) { - const {type: schemaType, const: schemaConst, enum: schemaEnum} = this._schema; + /** + * @param {import('json-schema').SchemaObject} schema + * @param {unknown} value + * @throws {Error} + */ + _validateSingleSchema(schema, value) { + const {type: schemaType, const: schemaConst, enum: schemaEnum} = schema; const type = this._getValueType(value); if (!this._isValueTypeAny(value, type, schemaType)) { throw this._createError(`Value type ${type} does not match schema type ${schemaType}`); @@ -570,22 +818,27 @@ export class JsonSchema { switch (type) { case 'number': - this._validateNumber(value); + this._validateNumber(schema, /** @type {number} */ (value)); break; case 'string': - this._validateString(value); + this._validateString(schema, /** @type {string} */ (value)); break; case 'array': - this._validateArray(value); + this._validateArray(schema, /** @type {import('json-schema').Value[]} */ (value)); break; case 'object': - this._validateObject(value); + this._validateObject(schema, /** @type {import('json-schema').ValueObject} */ (value)); break; } } - _validateNumber(value) { - const {multipleOf, minimum, exclusiveMinimum, maximum, exclusiveMaximum} = this._schema; + /** + * @param {import('json-schema').SchemaObject} schema + * @param {number} value + * @throws {Error} + */ + _validateNumber(schema, value) { + const {multipleOf, minimum, exclusiveMinimum, maximum, exclusiveMaximum} = schema; if (typeof multipleOf === 'number' && Math.floor(value / multipleOf) * multipleOf !== value) { throw this._createError(`Number is not a multiple of ${multipleOf}`); } @@ -607,8 +860,13 @@ export class JsonSchema { } } - _validateString(value) { - const {minLength, maxLength, pattern} = this._schema; + /** + * @param {import('json-schema').SchemaObject} schema + * @param {string} value + * @throws {Error} + */ + _validateString(schema, value) { + const {minLength, maxLength, pattern} = schema; if (typeof minLength === 'number' && value.length < minLength) { throw this._createError('String length too short'); } @@ -618,14 +876,14 @@ export class JsonSchema { } if (typeof pattern === 'string') { - let {patternFlags} = this._schema; + let {patternFlags} = schema; if (typeof patternFlags !== 'string') { patternFlags = ''; } let regex; try { regex = this._getRegex(pattern, patternFlags); } catch (e) { - throw this._createError(`Pattern is invalid (${e.message})`); + throw this._createError(`Pattern is invalid (${e instanceof Error ? e.message : `${e}`})`); } if (!regex.test(value)) { @@ -634,8 +892,13 @@ export class JsonSchema { } } - _validateArray(value) { - const {minItems, maxItems} = this._schema; + /** + * @param {import('json-schema').SchemaObject} schema + * @param {unknown[]} value + * @throws {Error} + */ + _validateArray(schema, value) { + const {minItems, maxItems} = schema; const {length} = value; if (typeof minItems === 'number' && length < minItems) { @@ -646,30 +909,35 @@ export class JsonSchema { throw this._createError('Array length too long'); } - this._validateArrayContains(value); + this._validateArrayContains(schema, value); for (let i = 0; i < length; ++i) { - const schemaInfo = this._getArrayItemSchemaInfo(i); - if (schemaInfo === null) { + const {schema: itemSchema, stack} = this._getArrayItemSchemaInfo(schema, i); + if (itemSchema === false) { throw this._createError(`No schema found for array[${i}]`); } const propertyValue = value[i]; - this._schemaPush(schemaInfo.schema, schemaInfo.path); + this._schemaPushMultiple(stack); this._valuePush(propertyValue, i); try { - this._validate(propertyValue); + this._validate(itemSchema, propertyValue); } finally { this._valuePop(); - this._schemaPop(); + this._schemaPopMultiple(stack.length); } } } - _validateArrayContains(value) { - const containsSchema = this._schema.contains; - if (!this._isObject(containsSchema)) { return; } + /** + * @param {import('json-schema').SchemaObject} schema + * @param {unknown[]} value + * @throws {Error} + */ + _validateArrayContains(schema, value) { + const containsSchema = schema.contains; + if (typeof containsSchema === 'undefined') { return; } this._schemaPush(containsSchema, 'contains'); try { @@ -677,7 +945,7 @@ export class JsonSchema { const propertyValue = value[i]; this._valuePush(propertyValue, i); try { - this._validate(propertyValue); + this._validate(containsSchema, propertyValue); return; } catch (e) { // NOP @@ -691,8 +959,13 @@ export class JsonSchema { } } - _validateObject(value) { - const {required, minProperties, maxProperties} = this._schema; + /** + * @param {import('json-schema').SchemaObject} schema + * @param {import('json-schema').ValueObject} value + * @throws {Error} + */ + _validateObject(schema, value) { + const {required, minProperties, maxProperties} = schema; const properties = Object.getOwnPropertyNames(value); const {length} = properties; @@ -714,27 +987,32 @@ export class JsonSchema { for (let i = 0; i < length; ++i) { const property = properties[i]; - const schemaInfo = this._getObjectPropertySchemaInfo(property); - if (schemaInfo === null) { + const {schema: propertySchema, stack} = this._getObjectPropertySchemaInfo(schema, property); + if (propertySchema === false) { throw this._createError(`No schema found for ${property}`); } const propertyValue = value[property]; - this._schemaPush(schemaInfo.schema, schemaInfo.path); + this._schemaPushMultiple(stack); this._valuePush(propertyValue, property); try { - this._validate(propertyValue); + this._validate(propertySchema, propertyValue); } finally { this._valuePop(); - this._schemaPop(); + this._schemaPopMultiple(stack.length); } } } // Creation + /** + * @param {import('json-schema').Type|import('json-schema').Type[]|undefined} type + * @returns {import('json-schema').Value} + */ _getDefaultTypeValue(type) { + if (Array.isArray(type)) { type = type[0]; } if (typeof type === 'string') { switch (type) { case 'null': @@ -755,95 +1033,122 @@ export class JsonSchema { return null; } - _getDefaultSchemaValue() { - const {type: schemaType, default: schemaDefault} = this._schema; + /** + * @param {import('json-schema').SchemaObject} schema + * @returns {import('json-schema').Value} + */ + _getDefaultSchemaValue(schema) { + const {type: schemaType, default: schemaDefault} = schema; return ( typeof schemaDefault !== 'undefined' && this._isValueTypeAny(schemaDefault, this._getValueType(schemaDefault), schemaType) ? - clone(schemaDefault) : + JsonSchema.clone(schemaDefault) : this._getDefaultTypeValue(schemaType) ); } - _getValidValueOrDefault(path, value, schemaInfo) { - schemaInfo = this._getResolveSchemaInfo(schemaInfo); - this._schemaPush(schemaInfo.schema, schemaInfo.path); + /** + * @param {import('json-schema').Schema} schema + * @param {string|number|null} path + * @param {unknown} value + * @param {import('json-schema').SchemaStackItem[]} stack + * @returns {import('json-schema').Value} + */ + _getValidValueOrDefault(schema, path, value, stack) { + ({schema, stack} = this._getResolvedSchemaInfo(schema, stack)); + this._schemaPushMultiple(stack); this._valuePush(value, path); try { - return this._getValidValueOrDefaultInner(value); + return this._getValidValueOrDefaultInner(schema, value); } finally { this._valuePop(); - this._schemaPop(); + this._schemaPopMultiple(stack.length); } } - _getValidValueOrDefaultInner(value) { + /** + * @param {import('json-schema').Schema} schema + * @param {unknown} value + * @returns {import('json-schema').Value} + */ + _getValidValueOrDefaultInner(schema, value) { let type = this._getValueType(value); - if (typeof value === 'undefined' || !this._isValueTypeAny(value, type, this._schema.type)) { - value = this._getDefaultSchemaValue(); + if (typeof schema === 'boolean') { + return type !== null ? /** @type {import('json-schema').ValueObject} */ (value) : null; + } + 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); - break; + return this._populateObjectDefaults(schema, /** @type {import('json-schema').ValueObject} */ (value)); case 'array': - value = this._populateArrayDefaults(value); - break; + return this._populateArrayDefaults(schema, /** @type {import('json-schema').Value[]} */ (value)); default: - if (!this._isValidCurrent(value)) { - const schemaDefault = this._getDefaultSchemaValue(); - if (this._isValidCurrent(schemaDefault)) { - value = schemaDefault; + if (!this._isValidCurrent(schema, value)) { + const schemaDefault = this._getDefaultSchemaValue(schema); + if (this._isValidCurrent(schema, schemaDefault)) { + return schemaDefault; } } break; } - return value; + return /** @type {import('json-schema').ValueObject} */ (value); } - _populateObjectDefaults(value) { + /** + * @param {import('json-schema').SchemaObject} schema + * @param {import('json-schema').ValueObject} value + * @returns {import('json-schema').ValueObject} + */ + _populateObjectDefaults(schema, value) { const properties = new Set(Object.getOwnPropertyNames(value)); - const {required} = this._schema; + const {required} = schema; if (Array.isArray(required)) { for (const property of required) { properties.delete(property); - const schemaInfo = this._getObjectPropertySchemaInfo(property); - if (schemaInfo === null) { continue; } + const {schema: propertySchema, stack} = this._getObjectPropertySchemaInfo(schema, property); + if (propertySchema === false) { continue; } const propertyValue = Object.prototype.hasOwnProperty.call(value, property) ? value[property] : void 0; - value[property] = this._getValidValueOrDefault(property, propertyValue, schemaInfo); + value[property] = this._getValidValueOrDefault(propertySchema, property, propertyValue, stack); } } for (const property of properties) { - const schemaInfo = this._getObjectPropertySchemaInfo(property); - if (schemaInfo === null) { + const {schema: propertySchema, stack} = this._getObjectPropertySchemaInfo(schema, property); + if (propertySchema === false) { Reflect.deleteProperty(value, property); } else { - value[property] = this._getValidValueOrDefault(property, value[property], schemaInfo); + value[property] = this._getValidValueOrDefault(propertySchema, property, value[property], stack); } } return value; } - _populateArrayDefaults(value) { + /** + * @param {import('json-schema').SchemaObject} schema + * @param {import('json-schema').Value[]} value + * @returns {import('json-schema').Value[]} + */ + _populateArrayDefaults(schema, value) { for (let i = 0, ii = value.length; i < ii; ++i) { - const schemaInfo = this._getArrayItemSchemaInfo(i); - if (schemaInfo === null) { continue; } + const {schema: itemSchema, stack} = this._getArrayItemSchemaInfo(schema, i); + if (itemSchema === false) { continue; } const propertyValue = value[i]; - value[i] = this._getValidValueOrDefault(i, propertyValue, schemaInfo); + value[i] = this._getValidValueOrDefault(itemSchema, i, propertyValue, stack); } - const {minItems, maxItems} = this._schema; + const {minItems, maxItems} = schema; if (typeof minItems === 'number' && value.length < minItems) { for (let i = value.length; i < minItems; ++i) { - const schemaInfo = this._getArrayItemSchemaInfo(i); - if (schemaInfo === null) { break; } - const item = this._getValidValueOrDefault(i, void 0, schemaInfo); + const {schema: itemSchema, stack} = this._getArrayItemSchemaInfo(schema, i); + if (itemSchema === false) { break; } + const item = this._getValidValueOrDefault(itemSchema, i, void 0, stack); value.push(item); } } @@ -856,115 +1161,187 @@ export class JsonSchema { } } +/** + * @implements {ProxyHandler<import('json-schema').ValueObjectOrArray>} + */ class JsonSchemaProxyHandler { - constructor(schema) { - this._schema = schema; + /** + * @param {JsonSchema} schemaValidator + */ + constructor(schemaValidator) { + /** @type {JsonSchema} */ + this._schemaValidator = schemaValidator; + /** @type {RegExp} */ this._numberPattern = /^(?:0|[1-9]\d*)$/; } + /** + * @param {import('json-schema').ValueObjectOrArray} target + * @returns {?import('core').UnknownObject} + */ getPrototypeOf(target) { return Object.getPrototypeOf(target); } + /** + * @type {(target: import('json-schema').ValueObjectOrArray, newPrototype: ?unknown) => boolean} + */ setPrototypeOf() { throw new Error('setPrototypeOf not supported'); } + /** + * @param {import('json-schema').ValueObjectOrArray} target + * @returns {boolean} + */ isExtensible(target) { return Object.isExtensible(target); } + /** + * @param {import('json-schema').ValueObjectOrArray} target + * @returns {boolean} + */ preventExtensions(target) { Object.preventExtensions(target); return true; } + /** + * @param {import('json-schema').ValueObjectOrArray} target + * @param {string|symbol} property + * @returns {PropertyDescriptor|undefined} + */ getOwnPropertyDescriptor(target, property) { return Object.getOwnPropertyDescriptor(target, property); } + /** + * @type {(target: import('json-schema').ValueObjectOrArray, property: string|symbol, attributes: PropertyDescriptor) => boolean} + */ defineProperty() { throw new Error('defineProperty not supported'); } + /** + * @param {import('json-schema').ValueObjectOrArray} target + * @param {string|symbol} property + * @returns {boolean} + */ has(target, property) { return property in target; } - get(target, property) { - if (typeof property === 'symbol') { return target[property]; } + /** + * @param {import('json-schema').ValueObjectOrArray} target + * @param {string|symbol} property + * @param {import('core').SafeAny} _receiver + * @returns {import('core').SafeAny} + */ + get(target, property, _receiver) { + if (typeof property === 'symbol') { return /** @type {import('core').UnknownObject} */ (target)[property]; } let propertySchema; if (Array.isArray(target)) { const index = this._getArrayIndex(property); if (index === null) { // Note: this does not currently wrap mutating functions like push, pop, shift, unshift, splice - return target[property]; + return /** @type {import('core').SafeAny} */ (target)[property]; } - property = index; - propertySchema = this._schema.getArrayItemSchema(property); + property = `${index}`; + propertySchema = this._schemaValidator.getArrayItemSchema(index); } else { - propertySchema = this._schema.getObjectPropertySchema(property); + propertySchema = this._schemaValidator.getObjectPropertySchema(property); } if (propertySchema === null) { return void 0; } - const value = target[property]; - return value !== null && typeof value === 'object' ? propertySchema.createProxy(value) : value; + const value = /** @type {import('core').UnknownObject} */ (target)[property]; + return value !== null && typeof value === 'object' ? propertySchema.createProxy(/** @type {import('json-schema').Value} */ (value)) : value; } + /** + * @param {import('json-schema').ValueObjectOrArray} target + * @param {string|number|symbol} property + * @param {import('core').SafeAny} value + * @returns {boolean} + * @throws {Error} + */ set(target, property, value) { - if (typeof property === 'symbol') { throw new Error(`Cannot assign symbol property ${property}`); } + if (typeof property === 'symbol') { throw new Error(`Cannot assign symbol property ${typeof property === 'symbol' ? '<symbol>' : property}`); } let propertySchema; if (Array.isArray(target)) { const index = this._getArrayIndex(property); if (index === null) { - target[property] = value; + /** @type {import('core').SafeAny} */ (target)[property] = value; return true; } if (index > target.length) { throw new Error('Array index out of range'); } property = index; - propertySchema = this._schema.getArrayItemSchema(property); + propertySchema = this._schemaValidator.getArrayItemSchema(property); } else { - propertySchema = this._schema.getObjectPropertySchema(property); + if (typeof property !== 'string') { + property = `${property}`; + } + propertySchema = this._schemaValidator.getObjectPropertySchema(property); } if (propertySchema === null) { throw new Error(`Property ${property} not supported`); } - value = clone(value); + value = JsonSchema.clone(value); propertySchema.validate(value); - target[property] = value; + /** @type {import('core').UnknownObject} */ (target)[property] = value; return true; } + /** + * @param {import('json-schema').ValueObjectOrArray} target + * @param {string|symbol} property + * @returns {boolean} + * @throws {Error} + */ deleteProperty(target, property) { const required = ( (typeof target === 'object' && target !== null) ? - (!Array.isArray(target) && this._schema.isObjectPropertyRequired(property)) : + (!Array.isArray(target) && typeof property === 'string' && this._schemaValidator.isObjectPropertyRequired(property)) : true ); if (required) { - throw new Error(`${property} cannot be deleted`); + throw new Error(`${typeof property === 'symbol' ? '<symbol>' : property} cannot be deleted`); } return Reflect.deleteProperty(target, property); } + /** + * @param {import('json-schema').ValueObjectOrArray} target + * @returns {ArrayLike<string|symbol>} + */ ownKeys(target) { return Reflect.ownKeys(target); } + /** + * @type {(target: import('json-schema').ValueObjectOrArray, thisArg: import('core').SafeAny, argArray: import('core').SafeAny[]) => import('core').SafeAny} + */ apply() { throw new Error('apply not supported'); } + /** + * @type {(target: import('json-schema').ValueObjectOrArray, argArray: import('core').SafeAny[], newTarget: import('core').SafeFunction) => import('json-schema').ValueObjectOrArray} + */ construct() { throw new Error('construct not supported'); } // Private + /** + * @param {string|symbol|number} property + * @returns {?number} + */ _getArrayIndex(property) { if (typeof property === 'string' && this._numberPattern.test(property)) { return Number.parseInt(property, 10); |