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 41var loadTypeSchema = require('utils').loadTypeSchema; 42var CHECK = requireNative('logging').CHECK; 43 44function isInstanceOfClass(instance, className) { 45 while ((instance = instance.__proto__)) { 46 if (instance.constructor.name == className) 47 return true; 48 } 49 return false; 50} 51 52function isOptionalValue(value) { 53 return typeof(value) === 'undefined' || value === null; 54} 55 56function 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 */ 78function JSONSchemaValidator() { 79 this.errors = []; 80 this.types = []; 81} 82 83JSONSchemaValidator.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 */ 112JSONSchemaValidator.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 */ 127JSONSchemaValidator.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 */ 153JSONSchemaValidator.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 */ 172JSONSchemaValidator.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 191JSONSchemaValidator.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 */ 200JSONSchemaValidator.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 */ 222JSONSchemaValidator.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 */ 236JSONSchemaValidator.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 */ 304JSONSchemaValidator.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 */ 326JSONSchemaValidator.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 */ 341JSONSchemaValidator.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 */ 402JSONSchemaValidator.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 */ 448JSONSchemaValidator.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 */ 464JSONSchemaValidator.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 */ 492JSONSchemaValidator.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 */ 511JSONSchemaValidator.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 */ 521JSONSchemaValidator.prototype.resetErrors = function() { 522 this.errors = []; 523}; 524 525exports.JSONSchemaValidator = JSONSchemaValidator; 526