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