• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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