Bug 1225715: Part 1 - Add support for patterned strings and properties in schemas. r?billm draft
authorKris Maglione <maglione.k@gmail.com>
Thu, 21 Jan 2016 14:54:57 -0800
changeset 324461 c5d132a20ecc6bd9f70cfaf7ea933926ee2bdc30
parent 323644 8fc9a5eeede3b4551dbcbf0a77fe40349914c83e
child 324462 7b6fcdd90fa71cd6aee8137bf7a97847685dc2ca
push id9920
push usermaglione.k@gmail.com
push dateSat, 23 Jan 2016 01:12:39 +0000
reviewersbillm
bugs1225715
milestone46.0a1
Bug 1225715: Part 1 - Add support for patterned strings and properties in schemas. r?billm
toolkit/components/extensions/Schemas.jsm
toolkit/components/extensions/test/xpcshell/test_ext_schemas.js
--- a/toolkit/components/extensions/Schemas.jsm
+++ b/toolkit/components/extensions/Schemas.jsm
@@ -40,16 +40,27 @@ function readJSON(uri) {
         resolve(JSON.parse(text));
       } catch (e) {
         reject(e);
       }
     });
   });
 }
 
+// Parses a regular expression, with support for the Python extended
+// syntax that allows setting flags by including the string (?im)
+function parsePattern(pattern) {
+  let flags = "";
+  let match = /^\(\?([im]*)\)(.*)/.exec(pattern);
+  if (match) {
+    [, flags, pattern] = match;
+  }
+  return new RegExp(pattern, flags);
+}
+
 function getValueBaseType(value) {
   let t = typeof(value);
   if (t == "object") {
     if (value === null) {
       return "null";
     } else if (Array.isArray(value)) {
       return "array";
     } else if (Object.prototype.toString.call(value) == "[object ArrayBuffer]") {
@@ -166,21 +177,22 @@ class RefType extends Type {
     if (!type) {
       throw new Error(`Internal error: Type ${this.reference} not found`);
     }
     return type.checkBaseType(baseType);
   }
 }
 
 class StringType extends Type {
-  constructor(enumeration, minLength, maxLength) {
+  constructor(enumeration, minLength, maxLength, pattern) {
     super();
     this.enumeration = enumeration;
     this.minLength = minLength;
     this.maxLength = maxLength;
+    this.pattern = pattern;
   }
 
   normalize(value) {
     let r = this.normalizeBase("string", value);
     if (r.error) {
       return r;
     }
 
@@ -193,16 +205,20 @@ class StringType extends Type {
 
     if (value.length < this.minLength) {
       return {error: `String ${JSON.stringify(value)} is too short (must be ${this.minLength})`};
     }
     if (value.length > this.maxLength) {
       return {error: `String ${JSON.stringify(value)} is too long (must be ${this.maxLength})`};
     }
 
+    if (this.pattern && !this.pattern.test(value)) {
+      return {error: `String ${JSON.stringify(value)} must match ${this.pattern}`};
+    }
+
     return r;
   }
 
   checkBaseType(baseType) {
     return baseType == "string";
   }
 
   inject(name, dest, wrapperFuncs) {
@@ -212,35 +228,37 @@ class StringType extends Type {
         let key = e.toUpperCase();
         obj[key] = e;
       }
     }
   }
 }
 
 class ObjectType extends Type {
-  constructor(properties, additionalProperties, isInstanceOf) {
+  constructor(properties, additionalProperties, patternProperties, isInstanceOf) {
     super();
     this.properties = properties;
     this.additionalProperties = additionalProperties;
+    this.patternProperties = patternProperties;
     this.isInstanceOf = isInstanceOf;
   }
 
   checkBaseType(baseType) {
     return baseType == "object";
   }
 
   normalize(value) {
     let v = this.normalizeBase("object", value);
     if (v.error) {
       return v;
     }
 
     if (this.isInstanceOf) {
       if (Object.keys(this.properties).length ||
+          this.patternProperties.length ||
           !(this.additionalProperties instanceof AnyType)) {
         throw new Error("InternalError: isInstanceOf can only be used with objects that are otherwise unrestricted");
       }
 
       if (!instanceOf(value, this.isInstanceOf)) {
         return {error: `Object must be an instance of ${this.isInstanceOf}`};
       }
 
@@ -276,18 +294,18 @@ class ObjectType extends Type {
           // Chrome ignores non-enumerable properties.
           continue;
         }
         properties[prop] = Cu.unwaiveXrays(desc.value);
       }
     }
 
     let result = {};
-    for (let prop of Object.keys(this.properties)) {
-      let {type, optional, unsupported} = this.properties[prop];
+    let checkProperty = (prop, propType) => {
+      let {type, optional, unsupported} = propType;
       if (unsupported) {
         if (prop in properties) {
           return {error: `Property "${prop}" is unsupported by Firefox`};
         }
       } else if (prop in properties) {
         if (optional && (properties[prop] === null || properties[prop] === undefined)) {
           result[prop] = null;
         } else {
@@ -297,30 +315,51 @@ class ObjectType extends Type {
           }
           result[prop] = r.value;
         }
       } else if (!optional) {
         return {error: `Property "${prop}" is required`};
       } else {
         result[prop] = null;
       }
+    };
+
+    for (let prop of Object.keys(this.properties)) {
+      let error = checkProperty(prop, this.properties[prop]);
+      if (error) {
+        return error;
+      }
     }
 
+    outer:
     for (let prop of Object.keys(properties)) {
-      if (!(prop in this.properties)) {
-        if (this.additionalProperties) {
-          let r = this.additionalProperties.normalize(properties[prop]);
-          if (r.error) {
-            return r;
+      if (prop in this.properties) {
+        continue;
+      }
+
+      for (let {pattern, type} of this.patternProperties) {
+        if (pattern.test(prop)) {
+          let error = checkProperty(prop, type);
+          if (error) {
+            return error;
           }
-          result[prop] = r.value;
-        } else {
-          return {error: `Unexpected property "${prop}"`};
+
+          continue outer;
         }
       }
+
+      if (this.additionalProperties) {
+        let r = this.additionalProperties.normalize(properties[prop]);
+        if (r.error) {
+          return r;
+        }
+        result[prop] = r.value;
+      } else {
+        return {error: `Unexpected property "${prop}"`};
+      }
     }
 
     return {value: result};
   }
 }
 
 class NumberType extends Type {
   normalize(value) {
@@ -658,53 +697,80 @@ this.Schemas = {
     if (!("type" in type)) {
       throw new Error(`Unexpected value for type: ${JSON.stringify(type)}`);
     }
 
     allowedProperties.add("type");
 
     // Otherwise it's a normal type...
     if (type.type == "string") {
-      checkTypeProperties("enum", "minLength", "maxLength");
+      checkTypeProperties("enum", "minLength", "maxLength", "pattern");
 
       let enumeration = type.enum || null;
       if (enumeration) {
         // The "enum" property is either a list of strings that are
         // valid values or else a list of {name, description} objects,
         // where the .name values are the valid values.
         enumeration = enumeration.map(e => {
           if (typeof(e) == "object") {
             return e.name;
           } else {
             return e;
           }
         });
       }
+      let pattern = null;
+      if (type.pattern) {
+        try {
+          pattern = parsePattern(type.pattern);
+        } catch (e) {
+          throw new Error(`Internal error: Invalid pattern ${JSON.stringify(type.pattern)}`);
+        }
+      }
       return new StringType(enumeration,
                             type.minLength || 0,
-                            type.maxLength || Infinity);
+                            type.maxLength || Infinity,
+                            pattern);
     } else if (type.type == "object") {
-      let properties = {};
+      let parseProperty = type => {
+        return {
+          type: this.parseType(namespaceName, type,
+                               ["optional", "unsupported", "deprecated"]),
+          optional: type.optional || false,
+          unsupported: type.unsupported || false,
+        };
+      };
+
+      let properties = Object.create(null);
       for (let propName of Object.keys(type.properties || {})) {
-        let propType = this.parseType(namespaceName, type.properties[propName],
-                                      ["optional", "unsupported", "deprecated"]);
-        properties[propName] = {
-          type: propType,
-          optional: type.properties[propName].optional || false,
-          unsupported: type.properties[propName].unsupported || false,
-        };
+        properties[propName] = parseProperty(type.properties[propName]);
+      }
+
+      let patternProperties = [];
+      for (let propName of Object.keys(type.patternProperties || {})) {
+        let pattern;
+        try {
+          pattern = parsePattern(propName);
+        } catch (e) {
+          throw new Error(`Internal error: Invalid property pattern ${JSON.stringify(propName)}`);
+        }
+
+        patternProperties.push({
+          pattern,
+          type: parseProperty(type.patternProperties[propName]),
+        });
       }
 
       let additionalProperties = null;
       if ("additionalProperties" in type) {
         additionalProperties = this.parseType(namespaceName, type.additionalProperties);
       }
 
-      checkTypeProperties("properties", "additionalProperties", "isInstanceOf");
-      return new ObjectType(properties, additionalProperties, type.isInstanceOf || null);
+      checkTypeProperties("properties", "additionalProperties", "patternProperties", "isInstanceOf");
+      return new ObjectType(properties, additionalProperties, patternProperties, type.isInstanceOf || null);
     } else if (type.type == "array") {
       checkTypeProperties("items", "minItems", "maxItems");
       return new ArrayType(this.parseType(namespaceName, type.items),
                            type.minItems || 0, type.maxItems || Infinity);
     } else if (type.type == "number") {
       checkTypeProperties();
       return new NumberType();
     } else if (type.type == "integer") {
--- a/toolkit/components/extensions/test/xpcshell/test_ext_schemas.js
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_schemas.js
@@ -121,16 +121,40 @@ let json = [
 
      {
        name: "quosimodo",
        type: "function",
        parameters: [
          {name: "xyz", type: "object", additionalProperties: {type: "any"}},
        ],
      },
+
+     {
+       name: "patternprop",
+       type: "function",
+       parameters: [
+         {
+           name: "obj",
+           type: "object",
+           properties: {"prop1": {type: "integer"}},
+           patternProperties: {
+             "(?i)^prop\\d+$": {type: "string"},
+             "^foo\\d+$": {type: "string", optional: true},
+           },
+         },
+       ],
+     },
+
+     {
+       name: "pattern",
+       type: "function",
+       parameters: [
+         {name: "arg", type: "string", pattern: "(?i)^[0-9a-f]+$"},
+       ],
+     },
    ],
 
    events: [
      {
        name: "onFoo",
        type: "function",
      },
 
@@ -278,21 +302,63 @@ add_task(function* () {
 
   root.testing.quasar({func: f});
   do_check_eq(JSON.stringify(tallied.slice(0, -1)), JSON.stringify(["call", "testing", "quasar"]));
   do_check_eq(tallied[3][0].func, f);
   tallied = null;
 
   root.testing.quosimodo({a: 10, b: 20, c: 30});
   verify("call", "testing", "quosimodo", [{a: 10, b: 20, c: 30}]);
+  tallied = null;
 
   Assert.throws(() => root.testing.quosimodo(10),
                 /Incorrect argument types/,
                 "should throw for wrong type");
 
+  root.testing.patternprop({prop1: 12, prop2: "42", Prop3: "43", foo1: "x"});
+  verify("call", "testing", "patternprop", [{prop1: 12, prop2: "42", Prop3: "43", foo1: "x"}]);
+  tallied = null;
+
+  root.testing.patternprop({prop1: 12});
+  verify("call", "testing", "patternprop", [{prop1: 12}]);
+  tallied = null;
+
+  root.testing.patternprop({prop1: 12, foo1: null});
+  verify("call", "testing", "patternprop", [{prop1: 12, foo1: null}]);
+  tallied = null;
+
+
+  Assert.throws(() => root.testing.patternprop({prop1: "12", prop2: "42"}),
+                /Expected integer instead of "12"/,
+                "should throw for wrong property type");
+
+  Assert.throws(() => root.testing.patternprop({prop1: 12, prop2: 42}),
+                /Expected string instead of 42/,
+                "should throw for wrong property type");
+
+  Assert.throws(() => root.testing.patternprop({prop1: 12, prop2: null}),
+                /Expected string instead of null/,
+                "should throw for wrong property type");
+
+  Assert.throws(() => root.testing.patternprop({prop1: 12, propx: "42"}),
+                /Unexpected property "propx"/,
+                "should throw for unexpected property");
+
+  Assert.throws(() => root.testing.patternprop({prop1: 12, Foo1: "x"}),
+                /Unexpected property "Foo1"/,
+                "should throw for unexpected property");
+
+  root.testing.pattern("DEADbeef");
+  verify("call", "testing", "pattern", ["DEADbeef"]);
+  tallied = null;
+
+  Assert.throws(() => root.testing.pattern("DEADcow"),
+                /String "DEADcow" must match \/\^\[0-9a-f\]\+\$\/i/,
+                "should throw for non-match");
+
   root.testing.onFoo.addListener(f);
   do_check_eq(JSON.stringify(tallied.slice(0, -1)), JSON.stringify(["addListener", "testing", "onFoo"]));
   do_check_eq(tallied[3][0], f);
   do_check_eq(JSON.stringify(tallied[3][1]), JSON.stringify([]));
   tallied = null;
 
   root.testing.onFoo.removeListener(f);
   do_check_eq(JSON.stringify(tallied.slice(0, -1)), JSON.stringify(["removeListener", "testing", "onFoo"]));