blob: 0e4db6863f0bce74d4192350bffac0d014ad3970 [file] [log] [blame]
// Copyright 2014 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// -----------------------------------------------------------------------------
// NOTE: If you change this file you need to touch
// extension_renderer_resources.grd to have your change take effect.
// -----------------------------------------------------------------------------
//==============================================================================
// This file contains a class that implements a subset of JSON Schema.
// See: http://www.json.com/json-schema-proposal/ for more details.
//
// The following features of JSON Schema are not implemented:
// - requires
// - unique
// - disallow
// - union types (but replaced with 'choices')
//
// The following properties are not applicable to the interface exposed by
// this class:
// - options
// - readonly
// - title
// - description
// - format
// - default
// - transient
// - hidden
//
// There are also these departures from the JSON Schema proposal:
// - function and undefined types are supported
// - null counts as 'unspecified' for optional values
// - added the 'choices' property, to allow specifying a list of possible types
// for a value
// - by default an "object" typed schema does not allow additional properties.
// if present, "additionalProperties" is to be a schema against which all
// additional properties will be validated.
//==============================================================================
var utils = require('utils');
var loggingNative = requireNative('logging');
var schemaRegistry = requireNative('schema_registry');
var CHECK = loggingNative.CHECK;
var DCHECK = loggingNative.DCHECK;
var WARNING = loggingNative.WARNING;
function loadTypeSchema(typeName, defaultSchema) {
var parts = $String.split(typeName, '.');
if (parts.length == 1) {
if (defaultSchema == null) {
WARNING('Trying to reference "' + typeName + '" ' +
'with neither namespace nor default schema.');
return null;
}
var types = defaultSchema.types;
} else {
var schemaName = $Array.join($Array.slice(parts, 0, parts.length - 1), '.');
var types = schemaRegistry.GetSchema(schemaName).types;
}
for (var i = 0; i < types.length; ++i) {
if (types[i].id == typeName)
return types[i];
}
return null;
}
function isInstanceOfClass(instance, className) {
while ((instance = instance.__proto__)) {
if (instance.constructor.name == className)
return true;
}
return false;
}
function isOptionalValue(value) {
return value === undefined || value === null;
}
function enumToString(enumValue) {
if (enumValue.name === undefined)
return enumValue;
return enumValue.name;
}
/**
* Validates an instance against a schema and accumulates errors. Usage:
*
* var validator = new JSONSchemaValidator();
* validator.validate(inst, schema);
* if (validator.errors.length == 0)
* console.log("Valid!");
* else
* console.log(validator.errors);
*
* The errors property contains a list of objects. Each object has two
* properties: "path" and "message". The "path" property contains the path to
* the key that had the problem, and the "message" property contains a sentence
* describing the error.
*/
function JSONSchemaValidator() {
this.errors = [];
this.types = [];
}
$Object.setPrototypeOf(JSONSchemaValidator.prototype, null);
var messages = {
__proto__: null,
invalidEnum: 'Value must be one of: [*].',
propertyRequired: 'Property is required.',
unexpectedProperty: 'Unexpected property.',
arrayMinItems: 'Array must have at least * items.',
arrayMaxItems: 'Array must not have more than * items.',
itemRequired: 'Item is required.',
stringMinLength: 'String must be at least * characters long.',
stringMaxLength: 'String must not be more than * characters long.',
stringPattern: 'String must match the pattern: *.',
numberFiniteNotNan: 'Value must not be *.',
numberMinValue: 'Value must not be less than *.',
numberMaxValue: 'Value must not be greater than *.',
numberIntValue: 'Value must fit in a 32-bit signed integer.',
numberMaxDecimal: 'Value must not have more than * decimal places.',
invalidType: "Expected '*' but got '*'.",
invalidTypeIntegerNumber:
"Expected 'integer' but got 'number', consider using Math.round().",
invalidChoice: 'Value does not match any valid type choices.',
invalidPropertyType: 'Missing property type.',
schemaRequired: 'Schema value required.',
unknownSchemaReference: 'Unknown schema reference: *.',
notInstance: 'Object must be an instance of *.',
};
/**
* Builds an error message. Key is the property in the |errors| object, and
* |opt_replacements| is an array of values to replace "*" characters with.
*/
utils.defineProperty(JSONSchemaValidator, 'formatError',
function(key, opt_replacements) {
var message = messages[key];
if (opt_replacements) {
for (var i = 0; i < opt_replacements.length; ++i) {
DCHECK($String.indexOf(message, '*') != -1, message);
message = $String.replace(message, '*', opt_replacements[i]);
}
}
DCHECK($String.indexOf(message, '*') == -1)
return message;
});
/**
* Classifies a value as one of the JSON schema primitive types. Note that we
* don't explicitly disallow 'function', because we want to allow functions in
* the input values.
*/
utils.defineProperty(JSONSchemaValidator, 'getType', function(value) {
// If we can determine the type safely in JS, it's fastest to do it here.
// However, Object types are difficult to classify, so we have to do it in
// C++.
var s = typeof value;
if (s === 'object')
return value === null ? 'null' : schemaRegistry.GetObjectType(value);
if (s === 'number')
return value % 1 === 0 ? 'integer' : 'number';
return s;
});
/**
* Add types that may be referenced by validated schemas that reference them
* with "$ref": <typeId>. Each type must be a valid schema and define an
* "id" property.
*/
JSONSchemaValidator.prototype.addTypes = function(typeOrTypeList) {
function addType(validator, type) {
if (!type.id)
throw new Error("Attempt to addType with missing 'id' property");
validator.types[type.id] = type;
}
if ($Array.isArray(typeOrTypeList)) {
for (var i = 0; i < typeOrTypeList.length; ++i) {
addType(this, typeOrTypeList[i]);
}
} else {
addType(this, typeOrTypeList);
}
}
/**
* Returns a list of strings of the types that this schema accepts.
*/
JSONSchemaValidator.prototype.getAllTypesForSchema = function(schema) {
var schemaTypes = [];
if (schema.type)
$Array.push(schemaTypes, schema.type);
if (schema.choices) {
for (var i = 0; i < schema.choices.length; ++i) {
var choiceTypes = this.getAllTypesForSchema(schema.choices[i]);
schemaTypes = $Array.concat(schemaTypes, choiceTypes);
}
}
var ref = schema['$ref'];
if (ref) {
var type = this.getOrAddType(ref);
CHECK(type, 'Could not find type ' + ref);
schemaTypes = $Array.concat(schemaTypes, this.getAllTypesForSchema(type));
}
return schemaTypes;
};
JSONSchemaValidator.prototype.getOrAddType = function(typeName) {
if (!this.types[typeName])
this.types[typeName] = loadTypeSchema(typeName);
return this.types[typeName];
};
/**
* Returns true if |schema| would accept an argument of type |type|.
*/
JSONSchemaValidator.prototype.isValidSchemaType = function(type, schema) {
if (type == 'any')
return true;
// TODO(kalman): I don't understand this code. How can type be "null"?
if (schema.optional && (type == 'null' || type == 'undefined'))
return true;
var schemaTypes = this.getAllTypesForSchema(schema);
for (var i = 0; i < schemaTypes.length; ++i) {
if (schemaTypes[i] == 'any' || type == schemaTypes[i] ||
(type == 'integer' && schemaTypes[i] == 'number'))
return true;
}
return false;
};
/**
* Returns true if there is a non-null argument that both |schema1| and
* |schema2| would accept.
*/
JSONSchemaValidator.prototype.checkSchemaOverlap = function(schema1, schema2) {
var schema1Types = this.getAllTypesForSchema(schema1);
for (var i = 0; i < schema1Types.length; ++i) {
if (this.isValidSchemaType(schema1Types[i], schema2))
return true;
}
return false;
};
/**
* Validates an instance against a schema. The instance can be any JavaScript
* value and will be validated recursively. When this method returns, the
* |errors| property will contain a list of errors, if any.
*/
JSONSchemaValidator.prototype.validate = function(instance, schema, opt_path) {
var path = opt_path || '';
if (!schema) {
this.addError(path, 'schemaRequired');
return;
}
// If this schema defines itself as reference type, save it in this.types.
if (schema.id)
this.types[schema.id] = schema;
// If the schema has an extends property, the instance must validate against
// that schema too.
if (schema.extends)
this.validate(instance, schema.extends, path);
// If the schema has a $ref property, the instance must validate against
// that schema too. It must be present in this.types to be referenced.
var ref = schema.$ref;
if (ref) {
if (!this.getOrAddType(ref))
this.addError(path, 'unknownSchemaReference', [ref]);
else
this.validate(instance, this.getOrAddType(ref), path)
}
// If the schema has a choices property, the instance must validate against at
// least one of the items in that array.
if (schema.choices) {
this.validateChoices(instance, schema, path);
return;
}
// If the schema has an enum property, the instance must be one of those
// values.
if (schema.enum) {
if (!this.validateEnum(instance, schema, path))
return;
}
if (schema.type && schema.type != 'any') {
if (!this.validateType(instance, schema, path))
return;
// Type-specific validation.
switch (schema.type) {
case 'object':
this.validateObject(instance, schema, path);
break;
case 'array':
this.validateArray(instance, schema, path);
break;
case 'string':
this.validateString(instance, schema, path);
break;
case 'number':
case 'integer':
this.validateNumber(instance, schema, path);
break;
}
}
};
/**
* Validates an instance against a choices schema. The instance must match at
* least one of the provided choices.
*/
JSONSchemaValidator.prototype.validateChoices =
function(instance, schema, path) {
var originalErrors = this.errors;
for (var i = 0; i < schema.choices.length; ++i) {
this.errors = [];
this.validate(instance, schema.choices[i], path);
if (this.errors.length == 0) {
this.errors = originalErrors;
return;
}
}
this.errors = originalErrors;
this.addError(path, 'invalidChoice');
};
/**
* Validates an instance against a schema with an enum type. Populates the
* |errors| property, and returns a boolean indicating whether the instance
* validates.
*/
JSONSchemaValidator.prototype.validateEnum = function(instance, schema, path) {
for (var i = 0; i < schema.enum.length; ++i) {
if (instance === enumToString(schema.enum[i]))
return true;
}
this.addError(path, 'invalidEnum',
[$Array.join($Array.map(schema.enum, enumToString), ', ')]);
return false;
};
/**
* Validates an instance against an object schema and populates the errors
* property.
*/
JSONSchemaValidator.prototype.validateObject =
function(instance, schema, path) {
if (schema.properties) {
$Array.forEach($Object.keys(schema.properties), function(prop) {
var propPath = path ? path + '.' + prop : prop;
if (schema.properties[prop] == undefined) {
this.addError(propPath, 'invalidPropertyType');
} else if (instance[prop] !== undefined && instance[prop] !== null) {
this.validate(instance[prop], schema.properties[prop], propPath);
} else if (!schema.properties[prop].optional) {
this.addError(propPath, 'propertyRequired');
}
}, this);
}
// If "instanceof" property is set, check that this object inherits from
// the specified constructor (function).
if (schema.isInstanceOf) {
if (!isInstanceOfClass(instance, schema.isInstanceOf))
this.addError(path || '', 'notInstance', [schema.isInstanceOf]);
}
// Exit early from additional property check if "type":"any" is defined.
if (schema.additionalProperties &&
schema.additionalProperties.type &&
schema.additionalProperties.type == 'any') {
return;
}
// By default, additional properties are not allowed on instance objects. This
// can be overridden by setting the additionalProperties property to a schema
// which any additional properties must validate against.
$Array.forEach($Object.keys(instance), function(prop) {
if (schema.properties && $Object.hasOwnProperty(schema.properties, prop))
return;
var propPath = path ? path + '.' + prop : prop;
if (schema.additionalProperties)
this.validate(instance[prop], schema.additionalProperties, propPath);
else
this.addError(propPath, 'unexpectedProperty');
}, this);
};
/**
* Validates an instance against an array schema and populates the errors
* property.
*/
JSONSchemaValidator.prototype.validateArray = function(instance, schema, path) {
var typeOfItems = JSONSchemaValidator.getType(schema.items);
if (typeOfItems == 'object') {
if (schema.minItems && instance.length < schema.minItems) {
this.addError(path, 'arrayMinItems', [schema.minItems]);
}
if (typeof schema.maxItems != 'undefined' &&
instance.length > schema.maxItems) {
this.addError(path, 'arrayMaxItems', [schema.maxItems]);
}
// If the items property is a single schema, each item in the array must
// have that schema.
for (var i = 0; i < instance.length; ++i) {
this.validate(instance[i], schema.items, path + '.' + i);
}
} else if (typeOfItems == 'array') {
// If the items property is an array of schemas, each item in the array must
// validate against the corresponding schema.
for (var i = 0; i < schema.items.length; ++i) {
var itemPath = path ? path + '.' + i : $String.self(i);
if ($Object.hasOwnProperty(instance, i) &&
!isOptionalValue(instance[i])) {
this.validate(instance[i], schema.items[i], itemPath);
} else if (!schema.items[i].optional) {
this.addError(itemPath, 'itemRequired');
}
}
if (schema.additionalProperties) {
for (var i = schema.items.length; i < instance.length; ++i) {
var itemPath = path ? path + '.' + i : $String.self(i);
this.validate(instance[i], schema.additionalProperties, itemPath);
}
} else if (instance.length > schema.items.length) {
this.addError(path, 'arrayMaxItems', [schema.items.length]);
}
}
};
/**
* Validates a string and populates the errors property.
*/
JSONSchemaValidator.prototype.validateString =
function(instance, schema, path) {
if (schema.minLength && instance.length < schema.minLength)
this.addError(path, 'stringMinLength', [schema.minLength]);
if (schema.maxLength && instance.length > schema.maxLength)
this.addError(path, 'stringMaxLength', [schema.maxLength]);
if (schema.pattern && !schema.pattern.test(instance))
this.addError(path, 'stringPattern', [schema.pattern]);
};
/**
* Validates a number and populates the errors property. The instance is
* assumed to be a number.
*/
JSONSchemaValidator.prototype.validateNumber =
function(instance, schema, path) {
// Forbid NaN, +Infinity, and -Infinity. Our APIs don't use them, and
// JSON serialization encodes them as 'null'. Re-evaluate supporting
// them if we add an API that could reasonably take them as a parameter.
if (isNaN(instance) ||
instance == Number.POSITIVE_INFINITY ||
instance == Number.NEGATIVE_INFINITY )
this.addError(path, 'numberFiniteNotNan', [instance]);
if (schema.minimum !== undefined && instance < schema.minimum)
this.addError(path, 'numberMinValue', [schema.minimum]);
if (schema.maximum !== undefined && instance > schema.maximum)
this.addError(path, 'numberMaxValue', [schema.maximum]);
// Check for integer values outside of -2^31..2^31-1.
if (schema.type === 'integer' && (instance | 0) !== instance)
this.addError(path, 'numberIntValue', []);
// We don't have a saved copy of Math, and it's not worth it just for a
// 10^x function.
var getPowerOfTen = function(pow) {
// '10' is kind of an arbitrary number of maximum decimal places, but it
// ensures we don't do anything crazy, and we should never need to restrict
// decimals to a number higher than that.
DCHECK(pow >= 1 && pow <= 10);
DCHECK(pow % 1 === 0);
var multiplier = 10;
while (--pow)
multiplier *= 10;
return multiplier;
};
if (schema.maxDecimal &&
(instance * getPowerOfTen(schema.maxDecimal)) % 1) {
this.addError(path, 'numberMaxDecimal', [schema.maxDecimal]);
}
};
/**
* Validates the primitive type of an instance and populates the errors
* property. Returns true if the instance validates, false otherwise.
*/
JSONSchemaValidator.prototype.validateType = function(instance, schema, path) {
var actualType = JSONSchemaValidator.getType(instance);
if (schema.type == actualType ||
(schema.type == 'number' && actualType == 'integer')) {
return true;
} else if (schema.type == 'integer' && actualType == 'number') {
this.addError(path, 'invalidTypeIntegerNumber');
return false;
} else {
this.addError(path, 'invalidType', [schema.type, actualType]);
return false;
}
};
/**
* Adds an error message. |key| is an index into the |messages| object.
* |replacements| is an array of values to replace '*' characters in the
* message.
*/
JSONSchemaValidator.prototype.addError = function(path, key, replacements) {
$Array.push(this.errors, {
__proto__: null,
path: path,
message: JSONSchemaValidator.formatError(key, replacements)
});
};
/**
* Resets errors to an empty list so you can call 'validate' again.
*/
JSONSchemaValidator.prototype.resetErrors = function() {
this.errors = [];
};
exports.$set('JSONSchemaValidator', JSONSchemaValidator);
exports.$set('loadTypeSchema', loadTypeSchema);