gecko-dev/toolkit/components/utils/JsonSchemaValidator.jsm

588 строки
18 KiB
JavaScript

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at http://mozilla.org/MPL/2.0/. */
/* This file implements a not-quite standard JSON schema validator. It differs
* from the spec in a few ways:
*
* - the spec doesn't allow custom types to be defined, but this validator
* defines "URL", "URLorEmpty", "origin" etc.
* - Strings are automatically converted to `URL` objects for the appropriate
* types.
* - It doesn't support "pattern" when matching strings.
* - The boolean type accepts (and casts) 0 and 1 as valid values.
*/
"use strict";
const { XPCOMUtils } = ChromeUtils.import(
"resource://gre/modules/XPCOMUtils.jsm"
);
XPCOMUtils.defineLazyGlobalGetters(this, ["URL"]);
XPCOMUtils.defineLazyGetter(this, "log", () => {
let { ConsoleAPI } = ChromeUtils.import("resource://gre/modules/Console.jsm");
return new ConsoleAPI({
prefix: "JsonSchemaValidator.jsm",
// tip: set maxLogLevel to "debug" and use log.debug() to create detailed
// messages during development. See LOG_LEVELS in Console.jsm for details.
maxLogLevel: "error",
});
});
var EXPORTED_SYMBOLS = ["JsonSchemaValidator"];
/**
* To validate a single value, use the static `JsonSchemaValidator.validate`
* method. If you need to validate multiple values, you instead might want to
* make a JsonSchemaValidator instance with the options you need and then call
* the `validate` instance method.
*/
class JsonSchemaValidator {
/**
* Validates a value against a schema.
*
* @param {*} value
* The value to validate.
* @param {object} schema
* The schema to validate against.
* @param {boolean} allowArrayNonMatchingItems
* When true:
* Invalid items in arrays will be ignored, and they won't be included in
* result.parsedValue.
* When false:
* Invalid items in arrays will cause validation to fail.
* @param {boolean} allowExplicitUndefinedProperties
* When true:
* `someProperty: undefined` will be allowed for non-required properties.
* When false:
* `someProperty: undefined` will cause validation to fail even for
* properties that are not required.
* @param {boolean} allowNullAsUndefinedProperties
* When true:
* `someProperty: null` will be allowed for non-required properties whose
* expected types are non-null.
* When false:
* `someProperty: null` will cause validation to fail for non-required
* properties, except for properties whose expected types are null.
* @param {boolean} allowExtraProperties
* When true:
* Properties that are not defined in the schema will be ignored, and they
* won't be included in result.parsedValue.
* When false:
* Properties that are not defined in the schema will cause validation to
* fail.
* @return {object}
* The result of the validation, an object that looks like this:
*
* {
* valid,
* parsedValue,
* error: {
* message,
* rootValue,
* rootSchema,
* invalidValue,
* invalidPropertyNameComponents,
* }
* }
*
* {boolean} valid
* True if validation is successful, false if not.
* {*} parsedValue
* If validation is successful, this is the validated value. It can
* differ from the passed-in value in the following ways:
* * If a type in the schema is "URL" or "URLorEmpty", the passed-in
* value can use a string instead and it will be converted into a
* `URL` object in parsedValue.
* * Some of the `allow*` parameters control the properties that appear.
* See above.
* {Error} error
* If validation fails, `error` will be present. It contains a number of
* properties useful for understanding the validation failure.
* {string} error.message
* The validation failure message.
* {*} error.rootValue
* The passed-in value.
* {object} error.rootSchema
* The passed-in schema.
* {*} invalidValue
* The value that caused validation to fail. If the passed-in value is a
* scalar type, this will be the value itself. If the value is an object
* or array, it will be the specific nested value in the object or array
* that caused validation to fail.
* {array} invalidPropertyNameComponents
* If the passed-in value is an object or array, this will contain the
* names of the object properties or array indexes where invalidValue can
* be found. For example, assume the passed-in value is:
* { foo: { bar: { baz: 123 }}}
* And assume `baz` should be a string instead of a number. Then
* invalidValue will be 123, and invalidPropertyNameComponents will be
* ["foo", "bar", "baz"], indicating that the erroneous property in the
* passed-in object is `foo.bar.baz`.
*/
static validate(
value,
schema,
{
allowArrayNonMatchingItems = false,
allowExplicitUndefinedProperties = false,
allowNullAsUndefinedProperties = false,
allowExtraProperties = false,
} = {}
) {
let validator = new JsonSchemaValidator({
allowArrayNonMatchingItems,
allowExplicitUndefinedProperties,
allowNullAsUndefinedProperties,
allowExtraProperties,
});
return validator.validate(value, schema);
}
/**
* Constructor.
*
* @param {boolean} allowArrayNonMatchingItems
* See the static `validate` method above.
* @param {boolean} allowExplicitUndefinedProperties
* See the static `validate` method above.
* @param {boolean} allowNullAsUndefinedProperties
* See the static `validate` method above.
* @param {boolean} allowExtraProperties
* See the static `validate` method above.
*/
constructor({
allowArrayNonMatchingItems = false,
allowExplicitUndefinedProperties = false,
allowNullAsUndefinedProperties = false,
allowExtraProperties = false,
} = {}) {
this.allowArrayNonMatchingItems = allowArrayNonMatchingItems;
this.allowExplicitUndefinedProperties = allowExplicitUndefinedProperties;
this.allowNullAsUndefinedProperties = allowNullAsUndefinedProperties;
this.allowExtraProperties = allowExtraProperties;
}
/**
* Validates a value against a schema.
*
* @param {*} value
* The value to validate.
* @param {object} schema
* The schema to validate against.
* @return {object}
* The result object. See the static `validate` method above.
*/
validate(value, schema) {
return this._validateRecursive(value, schema, [], {
rootValue: value,
rootSchema: schema,
});
}
// eslint-disable-next-line complexity
_validateRecursive(param, properties, keyPath, state) {
log.debug(`checking @${param}@ for type ${properties.type}`);
if (Array.isArray(properties.type)) {
log.debug("type is an array");
// For an array of types, the value is valid if it matches any of the
// listed types. To check this, make versions of the object definition
// that include only one type at a time, and check the value against each
// one.
for (const type of properties.type) {
let typeProperties = Object.assign({}, properties, { type });
log.debug(`checking subtype ${type}`);
let result = this._validateRecursive(
param,
typeProperties,
keyPath,
state
);
if (result.valid) {
return result;
}
}
// None of the types matched
return {
valid: false,
error: new JsonSchemaValidatorError({
message:
`The value '${valueToString(param)}' does not match any type in ` +
valueToString(properties.type),
value: param,
keyPath,
state,
}),
};
}
switch (properties.type) {
case "boolean":
case "number":
case "integer":
case "string":
case "URL":
case "URLorEmpty":
case "origin":
case "null": {
let result = this._validateSimpleParam(
param,
properties.type,
keyPath,
state
);
if (!result.valid) {
return result;
}
if (properties.enum && typeof result.parsedValue !== "boolean") {
if (!properties.enum.includes(param)) {
return {
valid: false,
error: new JsonSchemaValidatorError({
message:
`The value '${valueToString(param)}' is not one of the ` +
`enumerated values ${valueToString(properties.enum)}`,
value: param,
keyPath,
state,
}),
};
}
}
return result;
}
case "array":
if (!Array.isArray(param)) {
log.error("Array expected but not received");
return {
valid: false,
error: new JsonSchemaValidatorError({
message:
`The value '${valueToString(param)}' does not match the ` +
`expected type 'array'`,
value: param,
keyPath,
state,
}),
};
}
let parsedArray = [];
for (let i = 0; i < param.length; i++) {
let item = param[i];
log.debug(
`in array, checking @${item}@ for type ${properties.items.type}`
);
let result = this._validateRecursive(
item,
properties.items,
keyPath.concat(i),
state
);
if (!result.valid) {
if (
("strict" in properties && properties.strict) ||
(!("strict" in properties) && !this.allowArrayNonMatchingItems)
) {
return result;
}
continue;
}
parsedArray.push(result.parsedValue);
}
return { valid: true, parsedValue: parsedArray };
case "object": {
if (typeof param != "object" || !param) {
log.error("Object expected but not received");
return {
valid: false,
error: new JsonSchemaValidatorError({
message:
`The value '${valueToString(param)}' does not match the ` +
`expected type 'object'`,
value: param,
keyPath,
state,
}),
};
}
let parsedObj = {};
let patternProperties = [];
if ("patternProperties" in properties) {
for (let prop of Object.keys(properties.patternProperties || {})) {
let pattern;
try {
pattern = new RegExp(prop);
} catch (e) {
throw new Error(
`Internal error: Invalid property pattern ${prop}`
);
}
patternProperties.push({
pattern,
schema: properties.patternProperties[prop],
});
}
}
if (properties.required) {
for (let required of properties.required) {
if (!(required in param)) {
log.error(`Object is missing required property ${required}`);
return {
valid: false,
error: new JsonSchemaValidatorError({
message: `Object is missing required property '${required}'`,
value: param,
keyPath,
state,
}),
};
}
}
}
for (let item of Object.keys(param)) {
let schema;
if (
"properties" in properties &&
properties.properties.hasOwnProperty(item)
) {
schema = properties.properties[item];
} else if (patternProperties.length) {
for (let patternProperty of patternProperties) {
if (patternProperty.pattern.test(item)) {
schema = patternProperty.schema;
break;
}
}
}
if (!schema) {
let allowExtraProperties =
!properties.strict && this.allowExtraProperties;
if (allowExtraProperties) {
continue;
}
return {
valid: false,
error: new JsonSchemaValidatorError({
message: `Object has unexpected property '${item}'`,
value: param,
keyPath,
state,
}),
};
}
let allowExplicitUndefinedProperties =
!properties.strict && this.allowExplicitUndefinedProperties;
let allowNullAsUndefinedProperties =
!properties.strict && this.allowNullAsUndefinedProperties;
let isUndefined =
(!allowExplicitUndefinedProperties && !(item in param)) ||
(allowExplicitUndefinedProperties && param[item] === undefined) ||
(allowNullAsUndefinedProperties && param[item] === null);
if (isUndefined) {
continue;
}
let result = this._validateRecursive(
param[item],
schema,
keyPath.concat(item),
state
);
if (!result.valid) {
return result;
}
parsedObj[item] = result.parsedValue;
}
return { valid: true, parsedValue: parsedObj };
}
case "JSON":
if (typeof param == "object") {
return { valid: true, parsedValue: param };
}
try {
let json = JSON.parse(param);
if (typeof json != "object") {
log.error("JSON was not an object");
return {
valid: false,
error: new JsonSchemaValidatorError({
message: `JSON was not an object: ${valueToString(param)}`,
value: param,
keyPath,
state,
}),
};
}
return { valid: true, parsedValue: json };
} catch (e) {
log.error("JSON string couldn't be parsed");
return {
valid: false,
error: new JsonSchemaValidatorError({
message: `JSON string could not be parsed: ${valueToString(
param
)}`,
value: param,
keyPath,
state,
}),
};
}
}
return {
valid: false,
error: new JsonSchemaValidatorError({
message: `Invalid schema property type: ${valueToString(
properties.type
)}`,
value: param,
keyPath,
state,
}),
};
}
_validateSimpleParam(param, type, keyPath, state) {
let valid = false;
let parsedParam = param;
let error = undefined;
switch (type) {
case "boolean":
if (typeof param == "boolean") {
valid = true;
} else if (typeof param == "number" && (param == 0 || param == 1)) {
valid = true;
parsedParam = !!param;
}
break;
case "number":
case "string":
valid = typeof param == type;
break;
// integer is an alias to "number" that some JSON schema tools use
case "integer":
valid = typeof param == "number";
break;
case "null":
valid = param === null;
break;
case "origin":
if (typeof param != "string") {
break;
}
try {
parsedParam = new URL(param);
if (parsedParam.protocol == "file:") {
// Treat the entire file URL as an origin.
// Note this is stricter than the current Firefox policy,
// but consistent with Chrome.
// See https://bugzilla.mozilla.org/show_bug.cgi?id=803143
valid = true;
} else {
let pathQueryRef = parsedParam.pathname + parsedParam.hash;
// Make sure that "origin" types won't accept full URLs.
if (pathQueryRef != "/" && pathQueryRef != "") {
log.error(
`Ignoring parameter "${param}" - origin was expected but received full URL.`
);
valid = false;
} else {
valid = true;
}
}
} catch (ex) {
log.error(`Ignoring parameter "${param}" - not a valid origin.`);
valid = false;
}
break;
case "URL":
case "URLorEmpty":
if (typeof param != "string") {
break;
}
if (type == "URLorEmpty" && param === "") {
valid = true;
break;
}
try {
parsedParam = new URL(param);
valid = true;
} catch (ex) {
if (!param.startsWith("http")) {
log.error(
`Ignoring parameter "${param}" - scheme (http or https) must be specified.`
);
}
valid = false;
}
break;
}
if (!valid && !error) {
error = new JsonSchemaValidatorError({
message:
`The value '${valueToString(param)}' does not match the expected ` +
`type '${type}'`,
value: param,
keyPath,
state,
});
}
let result = {
valid,
parsedValue: parsedParam,
};
if (error) {
result.error = error;
}
return result;
}
}
class JsonSchemaValidatorError extends Error {
constructor({ message, value, keyPath, state } = {}, ...args) {
if (keyPath.length) {
message +=
". " +
`The invalid value is property '${keyPath.join(".")}' in ` +
JSON.stringify(state.rootValue);
}
super(message, ...args);
this.name = "JsonSchemaValidatorError";
this.rootValue = state.rootValue;
this.rootSchema = state.rootSchema;
this.invalidPropertyNameComponents = keyPath;
this.invalidValue = value;
}
}
function valueToString(value) {
try {
return JSON.stringify(value);
} catch (ex) {}
return String(value);
}