// 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": . 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);