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