aboutsummaryrefslogtreecommitdiff
path: root/ext/bg/js
diff options
context:
space:
mode:
authortoasted-nutbread <toasted-nutbread@users.noreply.github.com>2020-02-08 12:28:06 -0500
committerGitHub <noreply@github.com>2020-02-08 12:28:06 -0500
commit4e2b317d549c8b3ff95190e0c81c64e7d5d12111 (patch)
tree5bb9c94cad2d047cf340b8c4e598f35fa66e9277 /ext/bg/js
parent8eac191c97b755d7b090a46055b183750e6faa45 (diff)
parent3c28c7dd7cdbf4af91b0b4044f03e0877569e3b8 (diff)
Merge pull request #345 from toasted-nutbread/json-schema-improvements
JSON schema improvements
Diffstat (limited to 'ext/bg/js')
-rw-r--r--ext/bg/js/json-schema.js335
1 files changed, 277 insertions, 58 deletions
diff --git a/ext/bg/js/json-schema.js b/ext/bg/js/json-schema.js
index 5d596a8b..3cf24c35 100644
--- a/ext/bg/js/json-schema.js
+++ b/ext/bg/js/json-schema.js
@@ -64,7 +64,7 @@ class JsonSchemaProxyHandler {
}
}
- const propertySchema = JsonSchemaProxyHandler.getPropertySchema(this._schema, property);
+ const propertySchema = JsonSchemaProxyHandler.getPropertySchema(this._schema, property, target);
if (propertySchema === null) {
return;
}
@@ -86,17 +86,14 @@ class JsonSchemaProxyHandler {
}
}
- const propertySchema = JsonSchemaProxyHandler.getPropertySchema(this._schema, property);
+ const propertySchema = JsonSchemaProxyHandler.getPropertySchema(this._schema, property, target);
if (propertySchema === null) {
throw new Error(`Property ${property} not supported`);
}
value = JsonSchema.isolate(value);
- const error = JsonSchemaProxyHandler.validate(value, propertySchema);
- if (error !== null) {
- throw new Error(`Invalid value: ${error}`);
- }
+ JsonSchemaProxyHandler.validate(value, propertySchema, new JsonSchemaTraversalInfo(value, propertySchema));
target[property] = value;
return true;
@@ -122,151 +119,329 @@ class JsonSchemaProxyHandler {
throw new Error('construct not supported');
}
- static getPropertySchema(schema, property) {
- const type = schema.type;
- if (Array.isArray(type)) {
- throw new Error(`Ambiguous property type for ${property}`);
- }
+ static getPropertySchema(schema, property, value, path=null) {
+ const type = JsonSchemaProxyHandler.getSchemaOrValueType(schema, value);
switch (type) {
case 'object':
{
const properties = schema.properties;
- if (properties !== null && typeof properties === 'object' && !Array.isArray(properties)) {
- if (Object.prototype.hasOwnProperty.call(properties, property)) {
- return properties[property];
+ if (JsonSchemaProxyHandler.isObject(properties)) {
+ const propertySchema = properties[property];
+ if (JsonSchemaProxyHandler.isObject(propertySchema)) {
+ if (path !== null) { path.push(['properties', properties], [property, propertySchema]); }
+ return propertySchema;
}
}
const additionalProperties = schema.additionalProperties;
- return (additionalProperties !== null && typeof additionalProperties === 'object' && !Array.isArray(additionalProperties)) ? additionalProperties : null;
+ if (additionalProperties === false) {
+ return null;
+ } else if (JsonSchemaProxyHandler.isObject(additionalProperties)) {
+ if (path !== null) { path.push(['additionalProperties', additionalProperties]); }
+ return additionalProperties;
+ } else {
+ const result = JsonSchemaProxyHandler._unconstrainedSchema;
+ if (path !== null) { path.push([null, result]); }
+ return result;
+ }
}
case 'array':
{
const items = schema.items;
- return (items !== null && typeof items === 'object' && !Array.isArray(items)) ? items : null;
+ if (JsonSchemaProxyHandler.isObject(items)) {
+ return items;
+ }
+ if (Array.isArray(items)) {
+ if (property >= 0 && property < items.length) {
+ const propertySchema = items[property];
+ if (JsonSchemaProxyHandler.isObject(propertySchema)) {
+ if (path !== null) { path.push(['items', items], [property, propertySchema]); }
+ return propertySchema;
+ }
+ }
+ }
+
+ const additionalItems = schema.additionalItems;
+ if (additionalItems === false) {
+ return null;
+ } else if (JsonSchemaProxyHandler.isObject(additionalItems)) {
+ if (path !== null) { path.push(['additionalItems', additionalItems]); }
+ return additionalItems;
+ } else {
+ const result = JsonSchemaProxyHandler._unconstrainedSchema;
+ if (path !== null) { path.push([null, result]); }
+ return result;
+ }
}
default:
return null;
}
}
- static validate(value, schema) {
+ static getSchemaOrValueType(schema, value) {
+ const type = schema.type;
+
+ if (Array.isArray(type)) {
+ if (typeof value !== 'undefined') {
+ const valueType = JsonSchemaProxyHandler.getValueType(value);
+ if (type.indexOf(valueType) >= 0) {
+ return valueType;
+ }
+ }
+ return null;
+ }
+
+ if (typeof type === 'undefined') {
+ if (typeof value !== 'undefined') {
+ return JsonSchemaProxyHandler.getValueType(value);
+ }
+ return null;
+ }
+
+ return type;
+ }
+
+ static validate(value, schema, info) {
+ JsonSchemaProxyHandler.validateSingleSchema(value, schema, info);
+ JsonSchemaProxyHandler.validateConditional(value, schema, info);
+ JsonSchemaProxyHandler.validateAllOf(value, schema, info);
+ JsonSchemaProxyHandler.validateAnyOf(value, schema, info);
+ JsonSchemaProxyHandler.validateOneOf(value, schema, info);
+ JsonSchemaProxyHandler.validateNoneOf(value, schema, info);
+ }
+
+ static validateConditional(value, schema, info) {
+ const ifSchema = schema.if;
+ if (!JsonSchemaProxyHandler.isObject(ifSchema)) { return; }
+
+ let okay = true;
+ info.schemaPush('if', ifSchema);
+ try {
+ JsonSchemaProxyHandler.validate(value, ifSchema, info);
+ } catch (e) {
+ okay = false;
+ }
+ info.schemaPop();
+
+ const nextSchema = okay ? schema.then : schema.else;
+ if (JsonSchemaProxyHandler.isObject(nextSchema)) {
+ info.schemaPush(okay ? 'then' : 'else', nextSchema);
+ JsonSchemaProxyHandler.validate(value, nextSchema, info);
+ info.schemaPop();
+ }
+ }
+
+ static 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);
+ JsonSchemaProxyHandler.validate(value, subSchema, info);
+ info.schemaPop();
+ }
+ info.schemaPop();
+ }
+
+ static 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 {
+ JsonSchemaProxyHandler.validate(value, subSchema, info);
+ return;
+ } catch (e) {
+ // NOP
+ }
+ info.schemaPop();
+ }
+
+ throw new JsonSchemaValidationError('0 anyOf schemas matched', value, schema, info);
+ // info.schemaPop(); // Unreachable
+ }
+
+ static 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 {
+ JsonSchemaProxyHandler.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();
+ }
+
+ static 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 {
+ JsonSchemaProxyHandler.validate(value, subSchema, info);
+ } catch (e) {
+ info.schemaPop();
+ continue;
+ }
+ throw new JsonSchemaValidationError(`not[${i}] schema matched`, value, schema, info);
+ }
+ info.schemaPop();
+ }
+
+ static validateSingleSchema(value, schema, info) {
const type = JsonSchemaProxyHandler.getValueType(value);
const schemaType = schema.type;
if (!JsonSchemaProxyHandler.isValueTypeAny(value, type, schemaType)) {
- return `Value type ${type} does not match schema type ${schemaType}`;
+ throw new JsonSchemaValidationError(`Value type ${type} does not match schema type ${schemaType}`, value, schema, info);
}
const schemaEnum = schema.enum;
if (Array.isArray(schemaEnum) && !JsonSchemaProxyHandler.valuesAreEqualAny(value, schemaEnum)) {
- return 'Invalid enum value';
+ throw new JsonSchemaValidationError('Invalid enum value', value, schema, info);
}
switch (type) {
case 'number':
- return JsonSchemaProxyHandler.validateNumber(value, schema);
+ JsonSchemaProxyHandler.validateNumber(value, schema, info);
+ break;
case 'string':
- return JsonSchemaProxyHandler.validateString(value, schema);
+ JsonSchemaProxyHandler.validateString(value, schema, info);
+ break;
case 'array':
- return JsonSchemaProxyHandler.validateArray(value, schema);
+ JsonSchemaProxyHandler.validateArray(value, schema, info);
+ break;
case 'object':
- return JsonSchemaProxyHandler.validateObject(value, schema);
- default:
- return null;
+ JsonSchemaProxyHandler.validateObject(value, schema, info);
+ break;
}
}
- static validateNumber(value, schema) {
+ static validateNumber(value, schema, info) {
const multipleOf = schema.multipleOf;
if (typeof multipleOf === 'number' && Math.floor(value / multipleOf) * multipleOf !== value) {
- return `Number is not a multiple of ${multipleOf}`;
+ throw new JsonSchemaValidationError(`Number is not a multiple of ${multipleOf}`, value, schema, info);
}
const minimum = schema.minimum;
if (typeof minimum === 'number' && value < minimum) {
- return `Number is less than ${minimum}`;
+ throw new JsonSchemaValidationError(`Number is less than ${minimum}`, value, schema, info);
}
const exclusiveMinimum = schema.exclusiveMinimum;
if (typeof exclusiveMinimum === 'number' && value <= exclusiveMinimum) {
- return `Number is less than or equal to ${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) {
- return `Number is greater than ${maximum}`;
+ throw new JsonSchemaValidationError(`Number is greater than ${maximum}`, value, schema, info);
}
const exclusiveMaximum = schema.exclusiveMaximum;
if (typeof exclusiveMaximum === 'number' && value >= exclusiveMaximum) {
- return `Number is greater than or equal to ${exclusiveMaximum}`;
+ throw new JsonSchemaValidationError(`Number is greater than or equal to ${exclusiveMaximum}`, value, schema, info);
}
-
- return null;
}
- static validateString(value, schema) {
+ static validateString(value, schema, info) {
const minLength = schema.minLength;
if (typeof minLength === 'number' && value.length < minLength) {
- return 'String length too short';
+ throw new JsonSchemaValidationError('String length too short', value, schema, info);
}
- const maxLength = schema.minLength;
+ const maxLength = schema.maxLength;
if (typeof maxLength === 'number' && value.length > maxLength) {
- return 'String length too long';
+ throw new JsonSchemaValidationError('String length too long', value, schema, info);
}
-
- return null;
}
- static validateArray(value, schema) {
+ static validateArray(value, schema, info) {
const minItems = schema.minItems;
if (typeof minItems === 'number' && value.length < minItems) {
- return 'Array length too short';
+ throw new JsonSchemaValidationError('Array length too short', value, schema, info);
}
const maxItems = schema.maxItems;
if (typeof maxItems === 'number' && value.length > maxItems) {
- return 'Array length too long';
+ throw new JsonSchemaValidationError('Array length too long', value, schema, info);
}
- return null;
+ for (let i = 0, ii = value.length; i < ii; ++i) {
+ const schemaPath = [];
+ const propertySchema = JsonSchemaProxyHandler.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);
+ JsonSchemaProxyHandler.validate(propertyValue, propertySchema, info);
+ info.valuePop();
+ for (let i = 0; i < schemaPath.length; ++i) { info.schemaPop(); }
+ }
}
- static validateObject(value, schema) {
+ static 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)) {
- return `Missing property ${property}`;
+ throw new JsonSchemaValidationError(`Missing property ${property}`, value, schema, info);
}
}
}
const minProperties = schema.minProperties;
if (typeof minProperties === 'number' && properties.length < minProperties) {
- return 'Not enough object properties';
+ throw new JsonSchemaValidationError('Not enough object properties', value, schema, info);
}
const maxProperties = schema.maxProperties;
if (typeof maxProperties === 'number' && properties.length > maxProperties) {
- return 'Too many object properties';
+ throw new JsonSchemaValidationError('Too many object properties', value, schema, info);
}
for (const property of properties) {
- const propertySchema = JsonSchemaProxyHandler.getPropertySchema(schema, property);
+ const schemaPath = [];
+ const propertySchema = JsonSchemaProxyHandler.getPropertySchema(schema, property, value, schemaPath);
if (propertySchema === null) {
- return `No schema found for ${property}`;
- }
- const error = JsonSchemaProxyHandler.validate(value[property], propertySchema);
- if (error !== null) {
- return error;
+ throw new JsonSchemaValidationError(`No schema found for ${property}`, value, schema, info);
}
- }
- return null;
+ const propertyValue = value[property];
+
+ for (const [p, s] of schemaPath) { info.schemaPush(p, s); }
+ info.valuePush(property, propertyValue);
+ JsonSchemaProxyHandler.validate(propertyValue, propertySchema, info);
+ info.valuePop();
+ for (let i = 0; i < schemaPath.length; ++i) { info.schemaPop(); }
+ }
}
static isValueTypeAny(value, type, schemaTypes) {
@@ -372,14 +547,14 @@ class JsonSchemaProxyHandler {
for (const property of required) {
properties.delete(property);
- const propertySchema = JsonSchemaProxyHandler.getPropertySchema(schema, property);
+ const propertySchema = JsonSchemaProxyHandler.getPropertySchema(schema, property, value);
if (propertySchema === null) { continue; }
value[property] = JsonSchemaProxyHandler.getValidValueOrDefault(propertySchema, value[property]);
}
}
for (const property of properties) {
- const propertySchema = JsonSchemaProxyHandler.getPropertySchema(schema, property);
+ const propertySchema = JsonSchemaProxyHandler.getPropertySchema(schema, property, value);
if (propertySchema === null) {
Reflect.deleteProperty(value, property);
} else {
@@ -392,13 +567,53 @@ class JsonSchemaProxyHandler {
static populateArrayDefaults(value, schema) {
for (let i = 0, ii = value.length; i < ii; ++i) {
- const propertySchema = JsonSchemaProxyHandler.getPropertySchema(schema, i);
+ const propertySchema = JsonSchemaProxyHandler.getPropertySchema(schema, i, value);
if (propertySchema === null) { continue; }
value[i] = JsonSchemaProxyHandler.getValidValueOrDefault(propertySchema, value[i]);
}
return value;
}
+
+ static isObject(value) {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+ }
+}
+
+JsonSchemaProxyHandler._unconstrainedSchema = {};
+
+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;
+ }
}
class JsonSchema {
@@ -406,6 +621,10 @@ class JsonSchema {
return new Proxy(target, new JsonSchemaProxyHandler(schema));
}
+ static validate(value, schema) {
+ return JsonSchemaProxyHandler.validate(value, schema, new JsonSchemaTraversalInfo(value, schema));
+ }
+
static getValidValueOrDefault(schema, value) {
return JsonSchemaProxyHandler.getValidValueOrDefault(schema, value);
}