Home | History | Annotate | Download | only in extensions
      1 // Copyright (c) 2012 The Chromium Authors. All rights reserved.
      2 // Use of this source code is governed by a BSD-style license that can be
      3 // found in the LICENSE file.
      4 
      5 // -----------------------------------------------------------------------------
      6 // NOTE: If you change this file you need to touch renderer_resources.grd to
      7 // have your change take effect.
      8 // -----------------------------------------------------------------------------
      9 
     10 //==============================================================================
     11 // This file contains a class that implements a subset of JSON Schema.
     12 // See: http://www.json.com/json-schema-proposal/ for more details.
     13 //
     14 // The following features of JSON Schema are not implemented:
     15 // - requires
     16 // - unique
     17 // - disallow
     18 // - union types (but replaced with 'choices')
     19 //
     20 // The following properties are not applicable to the interface exposed by
     21 // this class:
     22 // - options
     23 // - readonly
     24 // - title
     25 // - description
     26 // - format
     27 // - default
     28 // - transient
     29 // - hidden
     30 //
     31 // There are also these departures from the JSON Schema proposal:
     32 // - function and undefined types are supported
     33 // - null counts as 'unspecified' for optional values
     34 // - added the 'choices' property, to allow specifying a list of possible types
     35 //   for a value
     36 // - by default an "object" typed schema does not allow additional properties.
     37 //   if present, "additionalProperties" is to be a schema against which all
     38 //   additional properties will be validated.
     39 //==============================================================================
     40 
     41 var loadTypeSchema = require('utils').loadTypeSchema;
     42 var CHECK = requireNative('logging').CHECK;
     43 
     44 function isInstanceOfClass(instance, className) {
     45   while ((instance = instance.__proto__)) {
     46     if (instance.constructor.name == className)
     47       return true;
     48   }
     49   return false;
     50 }
     51 
     52 function isOptionalValue(value) {
     53   return typeof(value) === 'undefined' || value === null;
     54 }
     55 
     56 /**
     57  * Validates an instance against a schema and accumulates errors. Usage:
     58  *
     59  * var validator = new JSONSchemaValidator();
     60  * validator.validate(inst, schema);
     61  * if (validator.errors.length == 0)
     62  *   console.log("Valid!");
     63  * else
     64  *   console.log(validator.errors);
     65  *
     66  * The errors property contains a list of objects. Each object has two
     67  * properties: "path" and "message". The "path" property contains the path to
     68  * the key that had the problem, and the "message" property contains a sentence
     69  * describing the error.
     70  */
     71 function JSONSchemaValidator() {
     72   this.errors = [];
     73   this.types = [];
     74 }
     75 
     76 JSONSchemaValidator.messages = {
     77   invalidEnum: "Value must be one of: [*].",
     78   propertyRequired: "Property is required.",
     79   unexpectedProperty: "Unexpected property.",
     80   arrayMinItems: "Array must have at least * items.",
     81   arrayMaxItems: "Array must not have more than * items.",
     82   itemRequired: "Item is required.",
     83   stringMinLength: "String must be at least * characters long.",
     84   stringMaxLength: "String must not be more than * characters long.",
     85   stringPattern: "String must match the pattern: *.",
     86   numberFiniteNotNan: "Value must not be *.",
     87   numberMinValue: "Value must not be less than *.",
     88   numberMaxValue: "Value must not be greater than *.",
     89   numberIntValue: "Value must fit in a 32-bit signed integer.",
     90   numberMaxDecimal: "Value must not have more than * decimal places.",
     91   invalidType: "Expected '*' but got '*'.",
     92   invalidTypeIntegerNumber:
     93       "Expected 'integer' but got 'number', consider using Math.round().",
     94   invalidChoice: "Value does not match any valid type choices.",
     95   invalidPropertyType: "Missing property type.",
     96   schemaRequired: "Schema value required.",
     97   unknownSchemaReference: "Unknown schema reference: *.",
     98   notInstance: "Object must be an instance of *."
     99 };
    100 
    101 /**
    102  * Builds an error message. Key is the property in the |errors| object, and
    103  * |opt_replacements| is an array of values to replace "*" characters with.
    104  */
    105 JSONSchemaValidator.formatError = function(key, opt_replacements) {
    106   var message = this.messages[key];
    107   if (opt_replacements) {
    108     for (var i = 0; i < opt_replacements.length; i++) {
    109       message = message.replace("*", opt_replacements[i]);
    110     }
    111   }
    112   return message;
    113 };
    114 
    115 /**
    116  * Classifies a value as one of the JSON schema primitive types. Note that we
    117  * don't explicitly disallow 'function', because we want to allow functions in
    118  * the input values.
    119  */
    120 JSONSchemaValidator.getType = function(value) {
    121   var s = typeof value;
    122 
    123   if (s == "object") {
    124     if (value === null) {
    125       return "null";
    126     } else if (Object.prototype.toString.call(value) == "[object Array]") {
    127       return "array";
    128     } else if (typeof(ArrayBuffer) != "undefined" &&
    129                value.constructor == ArrayBuffer) {
    130       return "binary";
    131     }
    132   } else if (s == "number") {
    133     if (value % 1 == 0) {
    134       return "integer";
    135     }
    136   }
    137 
    138   return s;
    139 };
    140 
    141 /**
    142  * Add types that may be referenced by validated schemas that reference them
    143  * with "$ref": <typeId>. Each type must be a valid schema and define an
    144  * "id" property.
    145  */
    146 JSONSchemaValidator.prototype.addTypes = function(typeOrTypeList) {
    147   function addType(validator, type) {
    148     if (!type.id)
    149       throw new Error("Attempt to addType with missing 'id' property");
    150     validator.types[type.id] = type;
    151   }
    152 
    153   if (typeOrTypeList instanceof Array) {
    154     for (var i = 0; i < typeOrTypeList.length; i++) {
    155       addType(this, typeOrTypeList[i]);
    156     }
    157   } else {
    158     addType(this, typeOrTypeList);
    159   }
    160 }
    161 
    162 /**
    163  * Returns a list of strings of the types that this schema accepts.
    164  */
    165 JSONSchemaValidator.prototype.getAllTypesForSchema = function(schema) {
    166   var schemaTypes = [];
    167   if (schema.type)
    168     $Array.push(schemaTypes, schema.type);
    169   if (schema.choices) {
    170     for (var i = 0; i < schema.choices.length; i++) {
    171       var choiceTypes = this.getAllTypesForSchema(schema.choices[i]);
    172       schemaTypes = $Array.concat(schemaTypes, choiceTypes);
    173     }
    174   }
    175   var ref = schema['$ref'];
    176   if (ref) {
    177     var type = this.getOrAddType(ref);
    178     CHECK(type, 'Could not find type ' + ref);
    179     schemaTypes = $Array.concat(schemaTypes, this.getAllTypesForSchema(type));
    180   }
    181   return schemaTypes;
    182 };
    183 
    184 JSONSchemaValidator.prototype.getOrAddType = function(typeName) {
    185   if (!this.types[typeName])
    186     this.types[typeName] = loadTypeSchema(typeName);
    187   return this.types[typeName];
    188 };
    189 
    190 /**
    191  * Returns true if |schema| would accept an argument of type |type|.
    192  */
    193 JSONSchemaValidator.prototype.isValidSchemaType = function(type, schema) {
    194   if (type == 'any')
    195     return true;
    196 
    197   // TODO(kalman): I don't understand this code. How can type be "null"?
    198   if (schema.optional && (type == "null" || type == "undefined"))
    199     return true;
    200 
    201   var schemaTypes = this.getAllTypesForSchema(schema);
    202   for (var i = 0; i < schemaTypes.length; i++) {
    203     if (schemaTypes[i] == "any" || type == schemaTypes[i])
    204       return true;
    205   }
    206 
    207   return false;
    208 };
    209 
    210 /**
    211  * Returns true if there is a non-null argument that both |schema1| and
    212  * |schema2| would accept.
    213  */
    214 JSONSchemaValidator.prototype.checkSchemaOverlap = function(schema1, schema2) {
    215   var schema1Types = this.getAllTypesForSchema(schema1);
    216   for (var i = 0; i < schema1Types.length; i++) {
    217     if (this.isValidSchemaType(schema1Types[i], schema2))
    218       return true;
    219   }
    220   return false;
    221 };
    222 
    223 /**
    224  * Validates an instance against a schema. The instance can be any JavaScript
    225  * value and will be validated recursively. When this method returns, the
    226  * |errors| property will contain a list of errors, if any.
    227  */
    228 JSONSchemaValidator.prototype.validate = function(instance, schema, opt_path) {
    229   var path = opt_path || "";
    230 
    231   if (!schema) {
    232     this.addError(path, "schemaRequired");
    233     return;
    234   }
    235 
    236   // If this schema defines itself as reference type, save it in this.types.
    237   if (schema.id)
    238     this.types[schema.id] = schema;
    239 
    240   // If the schema has an extends property, the instance must validate against
    241   // that schema too.
    242   if (schema.extends)
    243     this.validate(instance, schema.extends, path);
    244 
    245   // If the schema has a $ref property, the instance must validate against
    246   // that schema too. It must be present in this.types to be referenced.
    247   var ref = schema["$ref"];
    248   if (ref) {
    249     if (!this.getOrAddType(ref))
    250       this.addError(path, "unknownSchemaReference", [ ref ]);
    251     else
    252       this.validate(instance, this.getOrAddType(ref), path)
    253   }
    254 
    255   // If the schema has a choices property, the instance must validate against at
    256   // least one of the items in that array.
    257   if (schema.choices) {
    258     this.validateChoices(instance, schema, path);
    259     return;
    260   }
    261 
    262   // If the schema has an enum property, the instance must be one of those
    263   // values.
    264   if (schema.enum) {
    265     if (!this.validateEnum(instance, schema, path))
    266       return;
    267   }
    268 
    269   if (schema.type && schema.type != "any") {
    270     if (!this.validateType(instance, schema, path))
    271       return;
    272 
    273     // Type-specific validation.
    274     switch (schema.type) {
    275       case "object":
    276         this.validateObject(instance, schema, path);
    277         break;
    278       case "array":
    279         this.validateArray(instance, schema, path);
    280         break;
    281       case "string":
    282         this.validateString(instance, schema, path);
    283         break;
    284       case "number":
    285       case "integer":
    286         this.validateNumber(instance, schema, path);
    287         break;
    288     }
    289   }
    290 };
    291 
    292 /**
    293  * Validates an instance against a choices schema. The instance must match at
    294  * least one of the provided choices.
    295  */
    296 JSONSchemaValidator.prototype.validateChoices =
    297     function(instance, schema, path) {
    298   var originalErrors = this.errors;
    299 
    300   for (var i = 0; i < schema.choices.length; i++) {
    301     this.errors = [];
    302     this.validate(instance, schema.choices[i], path);
    303     if (this.errors.length == 0) {
    304       this.errors = originalErrors;
    305       return;
    306     }
    307   }
    308 
    309   this.errors = originalErrors;
    310   this.addError(path, "invalidChoice");
    311 };
    312 
    313 /**
    314  * Validates an instance against a schema with an enum type. Populates the
    315  * |errors| property, and returns a boolean indicating whether the instance
    316  * validates.
    317  */
    318 JSONSchemaValidator.prototype.validateEnum = function(instance, schema, path) {
    319   for (var i = 0; i < schema.enum.length; i++) {
    320     if (instance === schema.enum[i])
    321       return true;
    322   }
    323 
    324   this.addError(path, "invalidEnum", [schema.enum.join(", ")]);
    325   return false;
    326 };
    327 
    328 /**
    329  * Validates an instance against an object schema and populates the errors
    330  * property.
    331  */
    332 JSONSchemaValidator.prototype.validateObject =
    333     function(instance, schema, path) {
    334   if (schema.properties) {
    335     for (var prop in schema.properties) {
    336       // It is common in JavaScript to add properties to Object.prototype. This
    337       // check prevents such additions from being interpreted as required
    338       // schema properties.
    339       // TODO(aa): If it ever turns out that we actually want this to work,
    340       // there are other checks we could put here, like requiring that schema
    341       // properties be objects that have a 'type' property.
    342       if (!$Object.hasOwnProperty(schema.properties, prop))
    343         continue;
    344 
    345       var propPath = path ? path + "." + prop : prop;
    346       if (schema.properties[prop] == undefined) {
    347         this.addError(propPath, "invalidPropertyType");
    348       } else if (prop in instance && !isOptionalValue(instance[prop])) {
    349         this.validate(instance[prop], schema.properties[prop], propPath);
    350       } else if (!schema.properties[prop].optional) {
    351         this.addError(propPath, "propertyRequired");
    352       }
    353     }
    354   }
    355 
    356   // If "instanceof" property is set, check that this object inherits from
    357   // the specified constructor (function).
    358   if (schema.isInstanceOf) {
    359     if (!isInstanceOfClass(instance, schema.isInstanceOf))
    360       this.addError(propPath, "notInstance", [schema.isInstanceOf]);
    361   }
    362 
    363   // Exit early from additional property check if "type":"any" is defined.
    364   if (schema.additionalProperties &&
    365       schema.additionalProperties.type &&
    366       schema.additionalProperties.type == "any") {
    367     return;
    368   }
    369 
    370   // By default, additional properties are not allowed on instance objects. This
    371   // can be overridden by setting the additionalProperties property to a schema
    372   // which any additional properties must validate against.
    373   for (var prop in instance) {
    374     if (schema.properties && prop in schema.properties)
    375       continue;
    376 
    377     // Any properties inherited through the prototype are ignored.
    378     if (!$Object.hasOwnProperty(instance, prop))
    379       continue;
    380 
    381     var propPath = path ? path + "." + prop : prop;
    382     if (schema.additionalProperties)
    383       this.validate(instance[prop], schema.additionalProperties, propPath);
    384     else
    385       this.addError(propPath, "unexpectedProperty");
    386   }
    387 };
    388 
    389 /**
    390  * Validates an instance against an array schema and populates the errors
    391  * property.
    392  */
    393 JSONSchemaValidator.prototype.validateArray = function(instance, schema, path) {
    394   var typeOfItems = JSONSchemaValidator.getType(schema.items);
    395 
    396   if (typeOfItems == 'object') {
    397     if (schema.minItems && instance.length < schema.minItems) {
    398       this.addError(path, "arrayMinItems", [schema.minItems]);
    399     }
    400 
    401     if (typeof schema.maxItems != "undefined" &&
    402         instance.length > schema.maxItems) {
    403       this.addError(path, "arrayMaxItems", [schema.maxItems]);
    404     }
    405 
    406     // If the items property is a single schema, each item in the array must
    407     // have that schema.
    408     for (var i = 0; i < instance.length; i++) {
    409       this.validate(instance[i], schema.items, path + "." + i);
    410     }
    411   } else if (typeOfItems == 'array') {
    412     // If the items property is an array of schemas, each item in the array must
    413     // validate against the corresponding schema.
    414     for (var i = 0; i < schema.items.length; i++) {
    415       var itemPath = path ? path + "." + i : String(i);
    416       if (i in instance && !isOptionalValue(instance[i])) {
    417         this.validate(instance[i], schema.items[i], itemPath);
    418       } else if (!schema.items[i].optional) {
    419         this.addError(itemPath, "itemRequired");
    420       }
    421     }
    422 
    423     if (schema.additionalProperties) {
    424       for (var i = schema.items.length; i < instance.length; i++) {
    425         var itemPath = path ? path + "." + i : String(i);
    426         this.validate(instance[i], schema.additionalProperties, itemPath);
    427       }
    428     } else {
    429       if (instance.length > schema.items.length) {
    430         this.addError(path, "arrayMaxItems", [schema.items.length]);
    431       }
    432     }
    433   }
    434 };
    435 
    436 /**
    437  * Validates a string and populates the errors property.
    438  */
    439 JSONSchemaValidator.prototype.validateString =
    440     function(instance, schema, path) {
    441   if (schema.minLength && instance.length < schema.minLength)
    442     this.addError(path, "stringMinLength", [schema.minLength]);
    443 
    444   if (schema.maxLength && instance.length > schema.maxLength)
    445     this.addError(path, "stringMaxLength", [schema.maxLength]);
    446 
    447   if (schema.pattern && !schema.pattern.test(instance))
    448     this.addError(path, "stringPattern", [schema.pattern]);
    449 };
    450 
    451 /**
    452  * Validates a number and populates the errors property. The instance is
    453  * assumed to be a number.
    454  */
    455 JSONSchemaValidator.prototype.validateNumber =
    456     function(instance, schema, path) {
    457   // Forbid NaN, +Infinity, and -Infinity.  Our APIs don't use them, and
    458   // JSON serialization encodes them as 'null'.  Re-evaluate supporting
    459   // them if we add an API that could reasonably take them as a parameter.
    460   if (isNaN(instance) ||
    461       instance == Number.POSITIVE_INFINITY ||
    462       instance == Number.NEGATIVE_INFINITY )
    463     this.addError(path, "numberFiniteNotNan", [instance]);
    464 
    465   if (schema.minimum !== undefined && instance < schema.minimum)
    466     this.addError(path, "numberMinValue", [schema.minimum]);
    467 
    468   if (schema.maximum !== undefined && instance > schema.maximum)
    469     this.addError(path, "numberMaxValue", [schema.maximum]);
    470 
    471   // Check for integer values outside of -2^31..2^31-1.
    472   if (schema.type === "integer" && (instance | 0) !== instance)
    473     this.addError(path, "numberIntValue", []);
    474 
    475   if (schema.maxDecimal && instance * Math.pow(10, schema.maxDecimal) % 1)
    476     this.addError(path, "numberMaxDecimal", [schema.maxDecimal]);
    477 };
    478 
    479 /**
    480  * Validates the primitive type of an instance and populates the errors
    481  * property. Returns true if the instance validates, false otherwise.
    482  */
    483 JSONSchemaValidator.prototype.validateType = function(instance, schema, path) {
    484   var actualType = JSONSchemaValidator.getType(instance);
    485   if (schema.type == actualType ||
    486       (schema.type == "number" && actualType == "integer")) {
    487     return true;
    488   } else if (schema.type == "integer" && actualType == "number") {
    489     this.addError(path, "invalidTypeIntegerNumber");
    490     return false;
    491   } else {
    492     this.addError(path, "invalidType", [schema.type, actualType]);
    493     return false;
    494   }
    495 };
    496 
    497 /**
    498  * Adds an error message. |key| is an index into the |messages| object.
    499  * |replacements| is an array of values to replace '*' characters in the
    500  * message.
    501  */
    502 JSONSchemaValidator.prototype.addError = function(path, key, replacements) {
    503   $Array.push(this.errors, {
    504     path: path,
    505     message: JSONSchemaValidator.formatError(key, replacements)
    506   });
    507 };
    508 
    509 /**
    510  * Resets errors to an empty list so you can call 'validate' again.
    511  */
    512 JSONSchemaValidator.prototype.resetErrors = function() {
    513   this.errors = [];
    514 };
    515 
    516 exports.JSONSchemaValidator = JSONSchemaValidator;
    517