Bug 1323845: Part 1 - Support multiple schema root namespaces. r?aswan draft
authorKris Maglione <maglione.k@gmail.com>
Sat, 16 Dec 2017 15:05:13 -0600
changeset 718296 9dcd2ff1c93e41eb9771068e65aad350d295ba18
parent 718295 47f7a99c049b277bcbc7af2147c3e8d92c731410
child 718297 fb1aa3bbf66d6d6c4f0897977caf76cca2ce0c71
push id94869
push usermaglione.k@gmail.com
push dateWed, 10 Jan 2018 01:49:31 +0000
reviewersaswan
bugs1323845
milestone59.0a1
Bug 1323845: Part 1 - Support multiple schema root namespaces. r?aswan MozReview-Commit-ID: DfOjHGzLJro
toolkit/components/extensions/Schemas.jsm
toolkit/components/extensions/schemas/types.json
toolkit/components/extensions/test/xpcshell/test_ext_schemas_roots.js
toolkit/components/extensions/test/xpcshell/xpcshell.ini
tools/lint/eslint/modules.json
--- a/toolkit/components/extensions/Schemas.jsm
+++ b/toolkit/components/extensions/Schemas.jsm
@@ -29,17 +29,17 @@ XPCOMUtils.defineLazyModuleGetter(this, 
 XPCOMUtils.defineLazyModuleGetter(this, "NetUtil",
                                   "resource://gre/modules/NetUtil.jsm");
 XPCOMUtils.defineLazyServiceGetter(this, "contentPolicyService",
                                    "@mozilla.org/addons/content-policy;1",
                                    "nsIAddonContentPolicy");
 
 XPCOMUtils.defineLazyGetter(this, "StartupCache", () => ExtensionParent.StartupCache);
 
-this.EXPORTED_SYMBOLS = ["Schemas"];
+this.EXPORTED_SYMBOLS = ["SchemaRoot", "Schemas"];
 
 const {DEBUG} = AppConstants;
 
 const isParentProcess = Services.appinfo.processType === Services.appinfo.PROCESS_TYPE_DEFAULT;
 
 function readJSON(url) {
   return new Promise((resolve, reject) => {
     NetUtil.asyncFetch({uri: url, loadUsingSystemPrincipal: true}, (inputStream, status) => {
@@ -1111,33 +1111,35 @@ class Type extends Entry {
   static get EXTRA_PROPERTIES() {
     return ["description", "deprecated", "preprocess", "postprocess", "allowedContexts"];
   }
 
   /**
    * Parses the given schema object and returns an instance of this
    * class which corresponds to its properties.
    *
+   * @param {SchemaRoot} root
+   *        The root schema for this type.
    * @param {object} schema
    *        A JSON schema object which corresponds to a definition of
    *        this type.
    * @param {Array<string>} path
    *        The path to this schema object from the root schema,
    *        corresponding to the property names and array indices
    *        traversed during parsing in order to arrive at this schema
    *        object.
    * @param {Array<string>} [extraProperties]
    *        An array of extra property names which are valid for this
    *        schema in the current context.
    * @returns {Type}
    *        An instance of this type which corresponds to the given
    *        schema object.
    * @static
    */
-  static parseSchema(schema, path, extraProperties = []) {
+  static parseSchema(root, schema, path, extraProperties = []) {
     this.checkSchemaProperties(schema, path, extraProperties);
 
     return new this(schema);
   }
 
   /**
    * Checks that all of the properties present in the given schema
    * object are valid properties for this type, and throws if invalid.
@@ -1221,20 +1223,20 @@ class AnyType extends Type {
 }
 
 // An untagged union type.
 class ChoiceType extends Type {
   static get EXTRA_PROPERTIES() {
     return ["choices", ...super.EXTRA_PROPERTIES];
   }
 
-  static parseSchema(schema, path, extraProperties = []) {
+  static parseSchema(root, schema, path, extraProperties = []) {
     this.checkSchemaProperties(schema, path, extraProperties);
 
-    let choices = schema.choices.map(t => Schemas.parseSchema(t, path));
+    let choices = schema.choices.map(t => root.parseSchema(t, path));
     return new this(schema, choices);
   }
 
   constructor(schema, choices) {
     super(schema);
     this.choices = choices;
   }
 
@@ -1286,37 +1288,38 @@ class ChoiceType extends Type {
 }
 
 // This is a reference to another type--essentially a typedef.
 class RefType extends Type {
   static get EXTRA_PROPERTIES() {
     return ["$ref", ...super.EXTRA_PROPERTIES];
   }
 
-  static parseSchema(schema, path, extraProperties = []) {
+  static parseSchema(root, schema, path, extraProperties = []) {
     this.checkSchemaProperties(schema, path, extraProperties);
 
     let ref = schema.$ref;
-    let ns = path[0];
+    let ns = path.join(".");
     if (ref.includes(".")) {
-      [ns, ref] = ref.split(".");
+      [, ns, ref] = /^(.*)\.(.*?)$/.exec(ref);
     }
-    return new this(schema, ns, ref);
+    return new this(root, schema, ns, ref);
   }
 
   // For a reference to a type named T declared in namespace NS,
   // namespaceName will be NS and reference will be T.
-  constructor(schema, namespaceName, reference) {
+  constructor(root, schema, namespaceName, reference) {
     super(schema);
+    this.root = root;
     this.namespaceName = namespaceName;
     this.reference = reference;
   }
 
   get targetType() {
-    let ns = Schemas.getNamespace(this.namespaceName);
+    let ns = this.root.getNamespace(this.namespaceName);
     let type = ns.get(this.reference);
     if (!type) {
       throw new Error(`Internal error: Type ${this.reference} not found`);
     }
     return type;
   }
 
   normalize(value, context) {
@@ -1330,17 +1333,17 @@ class RefType extends Type {
 }
 
 class StringType extends Type {
   static get EXTRA_PROPERTIES() {
     return ["enum", "minLength", "maxLength", "pattern", "format",
             ...super.EXTRA_PROPERTIES];
   }
 
-  static parseSchema(schema, path, extraProperties = []) {
+  static parseSchema(root, schema, path, extraProperties = []) {
     this.checkSchemaProperties(schema, path, extraProperties);
 
     let enumeration = schema.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 => {
@@ -1462,31 +1465,31 @@ let FunctionEntry;
 let Event;
 let SubModuleType;
 
 class ObjectType extends Type {
   static get EXTRA_PROPERTIES() {
     return ["properties", "patternProperties", ...super.EXTRA_PROPERTIES];
   }
 
-  static parseSchema(schema, path, extraProperties = []) {
+  static parseSchema(root, schema, path, extraProperties = []) {
     if ("functions" in schema) {
-      return SubModuleType.parseSchema(schema, path, extraProperties);
+      return SubModuleType.parseSchema(root, schema, path, extraProperties);
     }
 
     if (DEBUG && !("$extend" in schema)) {
       // Only allow extending "properties" and "patternProperties".
       extraProperties = ["additionalProperties", "isInstanceOf", ...extraProperties];
     }
     this.checkSchemaProperties(schema, path, extraProperties);
 
     let parseProperty = (schema, extraProps = []) => {
       return {
-        type: Schemas.parseSchema(schema, path,
-                                  DEBUG && ["unsupported", "onError", "permissions", "default", ...extraProps]),
+        type: root.parseSchema(schema, path,
+                               DEBUG && ["unsupported", "onError", "permissions", "default", ...extraProps]),
         optional: schema.optional || false,
         unsupported: schema.unsupported || false,
         onError: schema.onError || null,
         default: schema.default === undefined ? null : schema.default,
       };
     };
 
     // Parse explicit "properties" object.
@@ -1514,17 +1517,17 @@ class ObjectType extends Type {
     // Parse "additionalProperties" schema.
     let additionalProperties = null;
     if (schema.additionalProperties) {
       let type = schema.additionalProperties;
       if (type === true) {
         type = {"type": "any"};
       }
 
-      additionalProperties = Schemas.parseSchema(type, path);
+      additionalProperties = root.parseSchema(type, path);
     }
 
     return new this(schema, properties, additionalProperties, patternProperties, schema.isInstanceOf || null);
   }
 
   constructor(schema, properties, additionalProperties, patternProperties, isInstanceOf) {
     super(schema);
     this.properties = properties;
@@ -1695,29 +1698,29 @@ class ObjectType extends Type {
 
 // This type is just a placeholder to be referred to by
 // SubModuleProperty. No value is ever expected to have this type.
 SubModuleType = class SubModuleType extends Type {
   static get EXTRA_PROPERTIES() {
     return ["functions", "events", "properties", ...super.EXTRA_PROPERTIES];
   }
 
-  static parseSchema(schema, path, extraProperties = []) {
+  static parseSchema(root, schema, path, extraProperties = []) {
     this.checkSchemaProperties(schema, path, extraProperties);
 
     // The path we pass in here is only used for error messages.
     path = [...path, schema.id];
     let functions = schema.functions.filter(fun => !fun.unsupported)
-                          .map(fun => FunctionEntry.parseSchema(fun, path));
+                          .map(fun => FunctionEntry.parseSchema(root, fun, path));
 
     let events = [];
 
     if (schema.events) {
       events = schema.events.filter(event => !event.unsupported)
-                     .map(event => Event.parseSchema(event, path));
+                     .map(event => Event.parseSchema(root, event, path));
     }
 
     return new this(functions, events);
   }
 
   constructor(functions, events) {
     super();
     this.functions = functions;
@@ -1745,17 +1748,17 @@ class NumberType extends Type {
   }
 }
 
 class IntegerType extends Type {
   static get EXTRA_PROPERTIES() {
     return ["minimum", "maximum", ...super.EXTRA_PROPERTIES];
   }
 
-  static parseSchema(schema, path, extraProperties = []) {
+  static parseSchema(root, schema, path, extraProperties = []) {
     this.checkSchemaProperties(schema, path, extraProperties);
 
     return new this(schema, schema.minimum || -Infinity, schema.maximum || Infinity);
   }
 
   constructor(schema, minimum, maximum) {
     super(schema);
     this.minimum = minimum;
@@ -1802,20 +1805,20 @@ class BooleanType extends Type {
   }
 }
 
 class ArrayType extends Type {
   static get EXTRA_PROPERTIES() {
     return ["items", "minItems", "maxItems", ...super.EXTRA_PROPERTIES];
   }
 
-  static parseSchema(schema, path, extraProperties = []) {
+  static parseSchema(root, schema, path, extraProperties = []) {
     this.checkSchemaProperties(schema, path, extraProperties);
 
-    let items = Schemas.parseSchema(schema.items, path, ["onError"]);
+    let items = root.parseSchema(schema.items, path, ["onError"]);
 
     return new this(schema, items, schema.minItems || 0, schema.maxItems || Infinity);
   }
 
   constructor(schema, itemType, minItems, maxItems) {
     super(schema);
     this.itemType = itemType;
     this.minItems = minItems;
@@ -1863,34 +1866,34 @@ class ArrayType extends Type {
 }
 
 class FunctionType extends Type {
   static get EXTRA_PROPERTIES() {
     return ["parameters", "async", "returns", "requireUserInput",
             ...super.EXTRA_PROPERTIES];
   }
 
-  static parseSchema(schema, path, extraProperties = []) {
+  static parseSchema(root, schema, path, extraProperties = []) {
     this.checkSchemaProperties(schema, path, extraProperties);
 
     let isAsync = !!schema.async;
     let isExpectingCallback = typeof schema.async === "string";
     let parameters = null;
     if ("parameters" in schema) {
       parameters = [];
       for (let param of schema.parameters) {
         // Callbacks default to optional for now, because of promise
         // handling.
         let isCallback = isAsync && param.name == schema.async;
         if (isCallback) {
           isExpectingCallback = false;
         }
 
         parameters.push({
-          type: Schemas.parseSchema(param, path, ["name", "optional", "default"]),
+          type: root.parseSchema(param, path, ["name", "optional", "default"]),
           name: param.name,
           optional: param.optional == null ? isCallback : param.optional,
           default: param.default == undefined ? null : param.default,
         });
       }
     }
     let hasAsyncCallback = false;
     if (isAsync) {
@@ -2014,34 +2017,35 @@ class SubModuleProperty extends Entry {
   // form of sub-module properties, where "$ref" points to a
   // SubModuleType containing a list of functions and "properties" is
   // a list of additional simple properties.
   //
   // name: Name of the property stuff is being added to.
   // namespaceName: Namespace in which the property lives.
   // reference: Name of the type defining the functions to add to the property.
   // properties: Additional properties to add to the module (unsupported).
-  constructor(schema, path, name, reference, properties, permissions) {
+  constructor(root, schema, path, name, reference, properties, permissions) {
     super(schema);
+    this.root = root;
     this.name = name;
     this.path = path;
     this.namespaceName = path.join(".");
     this.reference = reference;
     this.properties = properties;
     this.permissions = permissions;
   }
 
   getDescriptor(path, context) {
     let obj = Cu.createObjectIn(context.cloneScope);
 
-    let ns = Schemas.getNamespace(this.namespaceName);
+    let ns = this.root.getNamespace(this.namespaceName);
     let type = ns.get(this.reference);
     if (!type && this.reference.includes(".")) {
       let [namespaceName, ref] = this.reference.split(".");
-      ns = Schemas.getNamespace(namespaceName);
+      ns = this.root.getNamespace(namespaceName);
       type = ns.get(ref);
     }
 
     if (DEBUG) {
       if (!type || !(type instanceof SubModuleType)) {
         throw new Error(`Internal error: ${this.namespaceName}.${this.reference} ` +
                         `is not a sub-module`);
       }
@@ -2163,29 +2167,29 @@ class CallEntry extends Entry {
     });
 
     return fixedArgs;
   }
 }
 
 // Represents a "function" defined in a schema namespace.
 FunctionEntry = class FunctionEntry extends CallEntry {
-  static parseSchema(schema, path) {
+  static parseSchema(root, schema, path) {
     // When not in DEBUG mode, we just need to know *if* this returns.
     let returns = !!schema.returns;
     if (DEBUG && "returns" in schema) {
       returns = {
-        type: Schemas.parseSchema(schema.returns, path, ["optional", "name"]),
+        type: root.parseSchema(schema.returns, path, ["optional", "name"]),
         optional: schema.returns.optional || false,
         name: "result",
       };
     }
 
     return new this(schema, path, schema.name,
-                    Schemas.parseSchema(
+                    root.parseSchema(
                       schema, path,
                       ["name", "unsupported", "returns",
                        "permissions",
                        "allowAmbiguousOptionalArguments"]),
                     schema.unsupported || false,
                     schema.allowAmbiguousOptionalArguments || false,
                     returns,
                     schema.permissions || null);
@@ -2288,30 +2292,30 @@ FunctionEntry = class FunctionEntry exte
   }
 };
 
 // Represents an "event" defined in a schema namespace.
 //
 // TODO Bug 1369722: we should be able to remove the eslint-disable-line that follows
 // once Bug 1369722 has been fixed.
 Event = class Event extends CallEntry { // eslint-disable-line no-native-reassign
-  static parseSchema(event, path) {
+  static parseSchema(root, event, path) {
     let extraParameters = Array.from(event.extraParameters || [], param => ({
-      type: Schemas.parseSchema(param, path, ["name", "optional", "default"]),
+      type: root.parseSchema(param, path, ["name", "optional", "default"]),
       name: param.name,
       optional: param.optional || false,
       default: param.default == undefined ? null : param.default,
     }));
 
     let extraProperties = ["name", "unsupported", "permissions", "extraParameters",
                            // We ignore these properties for now.
                            "returns", "filters"];
 
     return new this(event, path, event.name,
-                    Schemas.parseSchema(event, path, extraProperties),
+                    root.parseSchema(event, path, extraProperties),
                     extraParameters,
                     event.unsupported || false,
                     event.permissions || null);
   }
 
   constructor(schema, path, name, type, extraParameters, unsupported, permissions) {
     super(schema, path, name, extraParameters);
     this.type = type;
@@ -2382,19 +2386,21 @@ const TYPES = Object.freeze(Object.assig
 const LOADERS = {
   events: "loadEvent",
   functions: "loadFunction",
   properties: "loadProperty",
   types: "loadType",
 };
 
 class Namespace extends Map {
-  constructor(name, path) {
+  constructor(root, name, path) {
     super();
 
+    this.root = root;
+
     this._lazySchemas = [];
     this.initialized = false;
 
     this.name = name;
     this.path = name ? [...path, name] : [...path];
 
     this.superNamespace = null;
 
@@ -2416,17 +2422,17 @@ class Namespace extends Map {
 
     for (let prop of ["permissions", "allowedContexts", "defaultContexts"]) {
       if (schema[prop]) {
         this[prop] = schema[prop];
       }
     }
 
     if (schema.$import) {
-      this.superNamespace = Schemas.getNamespace(schema.$import);
+      this.superNamespace = this.root.getNamespace(schema.$import);
     }
   }
 
   /**
    * Initializes the keys of this namespace based on the schema objects
    * added via previous `addSchema` calls.
    */
   init() {
@@ -2511,68 +2517,69 @@ class Namespace extends Map {
 
     return this.get(key);
   }
 
   loadType(name, type) {
     if ("$extend" in type) {
       return this.extendType(type);
     }
-    return Schemas.parseSchema(type, this.path, ["id"]);
+    return this.root.parseSchema(type, this.path, ["id"]);
   }
 
   extendType(type) {
     let targetType = this.get(type.$extend);
 
     // Only allow extending object and choices types for now.
     if (targetType instanceof ObjectType) {
       type.type = "object";
     } else if (DEBUG) {
       if (!targetType) {
         throw new Error(`Internal error: Attempt to extend a nonexistant type ${type.$extend}`);
       } else if (!(targetType instanceof ChoiceType)) {
         throw new Error(`Internal error: Attempt to extend a non-extensible type ${type.$extend}`);
       }
     }
 
-    let parsed = Schemas.parseSchema(type, this.path, ["$extend"]);
+    let parsed = this.root.parseSchema(type, this.path, ["$extend"]);
 
     if (DEBUG && parsed.constructor !== targetType.constructor) {
       throw new Error(`Internal error: Bad attempt to extend ${type.$extend}`);
     }
 
     targetType.extend(parsed);
 
     return targetType;
   }
 
   loadProperty(name, prop) {
     if ("$ref" in prop) {
       if (!prop.unsupported) {
-        return new SubModuleProperty(prop, this.path, name,
+        return new SubModuleProperty(this.root,
+                                     prop, this.path, name,
                                      prop.$ref, prop.properties || {},
                                      prop.permissions || null);
       }
     } else if ("value" in prop) {
       return new ValueProperty(prop, name, prop.value);
     } else {
       // We ignore the "optional" attribute on properties since we
       // don't inject anything here anyway.
-      let type = Schemas.parseSchema(prop, [this.name], ["optional", "permissions", "writable"]);
+      let type = this.root.parseSchema(prop, [this.name], ["optional", "permissions", "writable"]);
       return new TypeProperty(prop, this.path, name, type, prop.writable || false,
                               prop.permissions || null);
     }
   }
 
   loadFunction(name, fun) {
-    return FunctionEntry.parseSchema(fun, this.path);
+    return FunctionEntry.parseSchema(this.root, fun, this.path);
   }
 
   loadEvent(name, event) {
-    return Event.parseSchema(event, this.path);
+    return Event.parseSchema(this.root, event, this.path);
   }
 
   /**
    * Injects the properties of this namespace into the given object.
    *
    * @param {object} dest
    *        The object into which to inject the namespace properties.
    * @param {InjectionContext} context
@@ -2629,91 +2636,217 @@ class Namespace extends Map {
   /**
    * Returns a Namespace object for the given namespace name. If a
    * namespace object with this name does not already exist, it is
    * created. If the name contains any '.' characters, namespaces are
    * recursively created, for each dot-separated component.
    *
    * @param {string} name
    *        The name of the sub-namespace to retrieve.
+   * @param {boolean} [create = true]
+   *        If true, create any intermediate namespaces which don't
+   *        exist.
    *
    * @returns {Namespace}
    */
-  getNamespace(name) {
+  getNamespace(name, create = true) {
     let subName;
 
     let idx = name.indexOf(".");
     if (idx > 0) {
       subName = name.slice(idx + 1);
       name = name.slice(0, idx);
     }
 
     let ns = super.get(name);
     if (!ns) {
-      ns = new Namespace(name, this.path);
+      if (!create) {
+        return null;
+      }
+      ns = new Namespace(this.root, name, this.path);
       this.set(name, ns);
     }
 
     if (subName) {
       return ns.getNamespace(subName);
     }
     return ns;
   }
 
+  getOwnNamespace(name) {
+    return this.getNamespace(name);
+  }
+
   has(key) {
     this.init();
     return super.has(key);
   }
 }
 
+class SchemaRoot extends Namespace {
+  constructor(base, schemaJSON) {
+    super(null, "", []);
+
+    this.root = this;
+    this.base = base;
+    this.schemaJSON = schemaJSON;
+  }
+
+  getNamespace(name, create = true) {
+    let res = this.base && this.base.getNamespace(name, false);
+    if (res) {
+      return res;
+    }
+    return super.getNamespace(name, create);
+  }
+
+  getOwnNamespace(name) {
+    return super.getNamespace(name);
+  }
+
+  parseSchema(schema, path, extraProperties = []) {
+    let allowedProperties = DEBUG && new Set(extraProperties);
+
+    if ("choices" in schema) {
+      return ChoiceType.parseSchema(this, schema, path, allowedProperties);
+    } else if ("$ref" in schema) {
+      return RefType.parseSchema(this, schema, path, allowedProperties);
+    }
+
+    let type = TYPES[schema.type];
+
+    if (DEBUG) {
+      allowedProperties.add("type");
+
+      if (!("type" in schema)) {
+        throw new Error(`Unexpected value for type: ${JSON.stringify(schema)}`);
+      }
+
+      if (!type) {
+        throw new Error(`Unexpected type ${schema.type}`);
+      }
+    }
+
+    return type.parseSchema(this, schema, path, allowedProperties);
+  }
+
+  parseSchemas() {
+    for (let [key, schema] of this.schemaJSON.entries()) {
+      try {
+        if (typeof schema.deserialize === "function") {
+          schema = schema.deserialize(global);
+
+          // If we're in the parent process, we need to keep the
+          // StructuredCloneHolder blob around in order to send to future child
+          // processes. If we're in a child, we have no further use for it, so
+          // just store the deserialized schema data in its place.
+          if (!isParentProcess) {
+            this.schemaJSON.set(key, schema);
+          }
+        }
+
+        this.loadSchema(schema);
+      } catch (e) {
+        Cu.reportError(e);
+      }
+    }
+  }
+
+  loadSchema(json) {
+    for (let namespace of json) {
+      this.getOwnNamespace(namespace.namespace)
+          .addSchema(namespace);
+    }
+  }
+
+  /**
+   * Checks whether a given object has the necessary permissions to
+   * expose the given namespace.
+   *
+   * @param {string} namespace
+   *        The top-level namespace to check permissions for.
+   * @param {object} wrapperFuncs
+   *        Wrapper functions for the given context.
+   * @param {function} wrapperFuncs.hasPermission
+   *        A function which, when given a string argument, returns true
+   *        if the context has the given permission.
+   * @returns {boolean}
+   *        True if the context has permission for the given namespace.
+   */
+  checkPermissions(namespace, wrapperFuncs) {
+    let ns = this.getNamespace(namespace);
+    if (ns && ns.permissions) {
+      return ns.permissions.some(perm => wrapperFuncs.hasPermission(perm));
+    }
+    return true;
+  }
+
+  /**
+   * Inject registered extension APIs into `dest`.
+   *
+   * @param {object} dest The root namespace for the APIs.
+   *     This object is usually exposed to extensions as "chrome" or "browser".
+   * @param {object} wrapperFuncs An implementation of the InjectionContext
+   *     interface, which runs the actual functionality of the generated API.
+   */
+  inject(dest, wrapperFuncs) {
+    let context = new InjectionContext(wrapperFuncs);
+
+    if (this.base) {
+      this.base.injectInto(dest, context);
+    }
+    this.injectInto(dest, context);
+  }
+
+  /**
+   * Normalize `obj` according to the loaded schema for `typeName`.
+   *
+   * @param {object} obj The object to normalize against the schema.
+   * @param {string} typeName The name in the format namespace.propertyname
+   * @param {object} context An implementation of Context. Any validation errors
+   *     are reported to the given context.
+   * @returns {object} The normalized object.
+   */
+  normalize(obj, typeName, context) {
+    let [namespaceName, prop] = typeName.split(".");
+    let ns = this.getNamespace(namespaceName);
+    let type = ns.get(prop);
+
+    let result = type.normalize(obj, new Context(context));
+    if (result.error) {
+      return {error: forceString(result.error)};
+    }
+    return result;
+  }
+}
+
 this.Schemas = {
   initialized: false,
 
   REVOKE: Symbol("@@revoke"),
 
   // Maps a schema URL to the JSON contained in that schema file. This
   // is useful for sending the JSON across processes.
   schemaJSON: new Map(),
 
   // A separate map of schema JSON which should be available in all
   // content processes.
   contentSchemaJSON: new Map(),
 
-  // Map[<schema-name> -> Map[<symbol-name> -> Entry]]
-  // This keeps track of all the schemas that have been loaded so far.
-  rootNamespace: new Namespace("", []),
+  _rootSchema: null,
+
+  get rootSchema() {
+    if (!this.initialized) {
+      this.init();
+    }
+    return this._rootSchema;
+  },
 
   getNamespace(name) {
-    return this.rootNamespace.getNamespace(name);
-  },
-
-  parseSchema(schema, path, extraProperties = []) {
-    let allowedProperties = DEBUG && new Set(extraProperties);
-
-    if ("choices" in schema) {
-      return ChoiceType.parseSchema(schema, path, allowedProperties);
-    } else if ("$ref" in schema) {
-      return RefType.parseSchema(schema, path, allowedProperties);
-    }
-
-    let type = TYPES[schema.type];
-
-    if (DEBUG) {
-      allowedProperties.add("type");
-
-      if (!("type" in schema)) {
-        throw new Error(`Unexpected value for type: ${JSON.stringify(schema)}`);
-      }
-
-      if (!type) {
-        throw new Error(`Unexpected type ${schema.type}`);
-      }
-    }
-
-    return type.parseSchema(schema, path, allowedProperties);
+    return this.rootSchema.getNamespace(name);
   },
 
   init() {
     if (this.initialized) {
       return;
     }
     this.initialized = true;
 
@@ -2754,57 +2887,23 @@ this.Schemas = {
         break;
     }
   },
 
   _needFlush: true,
   flushSchemas() {
     if (this._needFlush) {
       this._needFlush = false;
-      XPCOMUtils.defineLazyGetter(this, "rootNamespace",
-                                  () => this.parseSchemas());
-    }
-  },
-
-  parseSchemas() {
-    this._needFlush = true;
-
-    Object.defineProperty(this, "rootNamespace", {
-      enumerable: true,
-      configurable: true,
-      value: new Namespace("", []),
-    });
-
-    for (let [key, schema] of this.schemaJSON.entries()) {
-      try {
-        if (typeof schema.deserialize === "function") {
-          schema = schema.deserialize(global);
-
-          // If we're in the parent process, we need to keep the
-          // StructuredCloneHolder blob around in order to send to future child
-          // processes. If we're in a child, we have no further use for it, so
-          // just store the deserialized schema data in its place.
-          if (!isParentProcess) {
-            this.schemaJSON.set(key, schema);
-          }
-        }
-
-        this.loadSchema(schema);
-      } catch (e) {
-        Cu.reportError(e);
-      }
-    }
-
-    return this.rootNamespace;
-  },
-
-  loadSchema(json) {
-    for (let namespace of json) {
-      this.getNamespace(namespace.namespace)
-          .addSchema(namespace);
+      XPCOMUtils.defineLazyGetter(this, "_rootSchema", () => {
+        this._needFlush = true;
+
+        let rootSchema = new SchemaRoot(null, this.schemaJSON);
+        rootSchema.parseSchemas();
+        return rootSchema;
+      });
     }
   },
 
   _loadCachedSchemasPromise: null,
   loadCachedSchemas() {
     if (!this._loadCachedSchemasPromise) {
       this._loadCachedSchemasPromise = StartupCache.schemas.getAll().then(results => {
         return results;
@@ -2867,64 +2966,38 @@ this.Schemas = {
    *        Wrapper functions for the given context.
    * @param {function} wrapperFuncs.hasPermission
    *        A function which, when given a string argument, returns true
    *        if the context has the given permission.
    * @returns {boolean}
    *        True if the context has permission for the given namespace.
    */
   checkPermissions(namespace, wrapperFuncs) {
-    if (!this.initialized) {
-      this.init();
-    }
-
-    let ns = this.getNamespace(namespace);
-    if (ns && ns.permissions) {
-      return ns.permissions.some(perm => wrapperFuncs.hasPermission(perm));
-    }
-    return true;
+    return this.rootSchema.checkPermissions(namespace, wrapperFuncs);
   },
 
   exportLazyGetter,
 
   /**
    * Inject registered extension APIs into `dest`.
    *
    * @param {object} dest The root namespace for the APIs.
    *     This object is usually exposed to extensions as "chrome" or "browser".
    * @param {object} wrapperFuncs An implementation of the InjectionContext
    *     interface, which runs the actual functionality of the generated API.
    */
   inject(dest, wrapperFuncs) {
-    if (!this.initialized) {
-      this.init();
-    }
-
-    let context = new InjectionContext(wrapperFuncs);
-
-    this.rootNamespace.injectInto(dest, context);
+    this.rootSchema.inject(dest, wrapperFuncs);
   },
 
   /**
    * Normalize `obj` according to the loaded schema for `typeName`.
    *
    * @param {object} obj The object to normalize against the schema.
    * @param {string} typeName The name in the format namespace.propertyname
    * @param {object} context An implementation of Context. Any validation errors
    *     are reported to the given context.
    * @returns {object} The normalized object.
    */
   normalize(obj, typeName, context) {
-    if (!this.initialized) {
-      this.init();
-    }
-
-    let [namespaceName, prop] = typeName.split(".");
-    let ns = this.getNamespace(namespaceName);
-    let type = ns.get(prop);
-
-    let result = type.normalize(obj, new Context(context));
-    if (result.error) {
-      return {error: forceString(result.error)};
-    }
-    return result;
+    return this.rootSchema.normalize(obj, typeName, context);
   },
 };
--- a/toolkit/components/extensions/schemas/types.json
+++ b/toolkit/components/extensions/schemas/types.json
@@ -80,17 +80,17 @@
                 "type": "object",
                 "description": "Which setting to change.",
                 "properties": {
                   "value": {
                     "description": "The value of the setting. <br/>Note that every setting has a specific value type, which is described together with the setting. An extension should <em>not</em> set a value of a different type.",
                     "type": "any"
                   },
                   "scope": {
-                    "$ref": "SettingScope",
+                    "$ref": "types.SettingScope",
                     "optional": true,
                     "description": "Where to set the setting (default: regular)."
                   }
                 }
               },
               {
                 "name": "callback",
                 "type": "function",
@@ -107,17 +107,17 @@
             "async": "callback",
             "parameters": [
               {
                 "name": "details",
                 "type": "object",
                 "description": "Which setting to clear.",
                 "properties": {
                   "scope": {
-                    "$ref": "SettingScope",
+                    "$ref": "types.SettingScope",
                     "optional": true,
                     "description": "Where to clear the setting (default: regular)."
                   }
                 }
               },
               {
                 "name": "callback",
                 "type": "function",
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_roots.js
@@ -0,0 +1,239 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+Components.utils.import("resource://gre/modules/Schemas.jsm");
+Components.utils.import("resource://gre/modules/ExtensionCommon.jsm");
+
+let {SchemaAPIInterface} = ExtensionCommon;
+
+const global = this;
+
+let baseSchemaJSON = [
+  {
+    namespace: "base",
+
+    properties: {
+      PROP1: {value: 42},
+    },
+
+    types: [
+      {
+        id: "type1",
+        type: "string",
+        "enum": ["value1", "value2", "value3"],
+      },
+    ],
+
+    functions: [
+      {
+        name: "foo",
+        type: "function",
+        parameters: [
+          {name: "arg1", $ref: "type1"},
+        ],
+      },
+    ],
+  },
+];
+
+let experimentFooJSON = [
+  {
+    namespace: "experiments.foo",
+    types: [
+      {
+        id: "typeFoo",
+        type: "string",
+        "enum": ["foo1", "foo2", "foo3"],
+      },
+    ],
+
+    functions: [
+      {
+        name: "foo",
+        type: "function",
+        parameters: [
+          {name: "arg1", $ref: "typeFoo"},
+          {name: "arg2", $ref: "base.type1"},
+        ],
+      },
+    ],
+  },
+];
+
+let experimentBarJSON = [
+  {
+    namespace: "experiments.bar",
+    types: [
+      {
+        id: "typeBar",
+        type: "string",
+        "enum": ["bar1", "bar2", "bar3"],
+      },
+    ],
+
+    functions: [
+      {
+        name: "bar",
+        type: "function",
+        parameters: [
+          {name: "arg1", $ref: "typeBar"},
+          {name: "arg2", $ref: "base.type1"},
+        ],
+      },
+    ],
+  },
+];
+
+
+let tallied = null;
+
+function tally(kind, ns, name, args) {
+  tallied = [kind, ns, name, args];
+}
+
+function verify(...args) {
+  do_check_eq(JSON.stringify(tallied), JSON.stringify(args));
+  tallied = null;
+}
+
+let talliedErrors = [];
+
+let permissions = new Set();
+
+class TallyingAPIImplementation extends SchemaAPIInterface {
+  constructor(namespace, name) {
+    super();
+    this.namespace = namespace;
+    this.name = name;
+  }
+
+  callFunction(args) {
+    tally("call", this.namespace, this.name, args);
+    if (this.name === "sub_foo") {
+      return 13;
+    }
+  }
+
+  callFunctionNoReturn(args) {
+    tally("call", this.namespace, this.name, args);
+  }
+
+  getProperty() {
+    tally("get", this.namespace, this.name);
+  }
+
+  setProperty(value) {
+    tally("set", this.namespace, this.name, value);
+  }
+
+  addListener(listener, args) {
+    tally("addListener", this.namespace, this.name, [listener, args]);
+  }
+
+  removeListener(listener) {
+    tally("removeListener", this.namespace, this.name, [listener]);
+  }
+
+  hasListener(listener) {
+    tally("hasListener", this.namespace, this.name, [listener]);
+  }
+}
+
+let wrapper = {
+  url: "moz-extension://b66e3509-cdb3-44f6-8eb8-c8b39b3a1d27/",
+
+  cloneScope: global,
+
+  checkLoadURL(url) {
+    return !url.startsWith("chrome:");
+  },
+
+  preprocessors: {
+    localize(value, context) {
+      return value.replace(/__MSG_(.*?)__/g, (m0, m1) => `${m1.toUpperCase()}`);
+    },
+  },
+
+  logError(message) {
+    talliedErrors.push(message);
+  },
+
+  hasPermission(permission) {
+    return permissions.has(permission);
+  },
+
+  shouldInject(ns, name) {
+    return name != "do-not-inject";
+  },
+
+  getImplementation(namespace, name) {
+    return new TallyingAPIImplementation(namespace, name);
+  },
+};
+
+add_task(async function() {
+  let baseSchemas = new Map([
+    ["resource://schemas/base.json", baseSchemaJSON],
+  ]);
+  let experimentSchemas = new Map([
+    ["resource://experiment-foo/schema.json", experimentFooJSON],
+    ["resource://experiment-bar/schema.json", experimentBarJSON],
+  ]);
+
+  let baseSchema = new SchemaRoot(null, baseSchemas);
+  let schema = new SchemaRoot(baseSchema, experimentSchemas);
+
+  baseSchema.parseSchemas();
+  schema.parseSchemas();
+
+  let root = {};
+  let base = {};
+
+  tallied = null;
+
+  baseSchema.inject(base, wrapper);
+  schema.inject(root, wrapper);
+
+  equal(typeof base.base, "object", "base.base exists");
+  equal(typeof root.base, "object", "root.base exists");
+  equal(typeof base.experiments, "undefined", "base.experiments exists not");
+  equal(typeof root.experiments, "object", "root.experiments exists");
+  equal(typeof root.experiments.foo, "object", "root.experiments.foo exists");
+  equal(typeof root.experiments.bar, "object", "root.experiments.bar exists");
+
+  do_check_eq(tallied, null);
+
+  do_check_eq(root.base.PROP1, 42, "root.base.PROP1");
+  do_check_eq(base.base.PROP1, 42, "root.base.PROP1");
+
+  root.base.foo("value2");
+  verify("call", "base", "foo", ["value2"]);
+
+  base.base.foo("value3");
+  verify("call", "base", "foo", ["value3"]);
+
+
+  root.experiments.foo.foo("foo2", "value1");
+  verify("call", "experiments.foo", "foo", ["foo2", "value1"]);
+
+  root.experiments.bar.bar("bar2", "value1");
+  verify("call", "experiments.bar", "bar", ["bar2", "value1"]);
+
+
+  Assert.throws(() => root.base.foo("Meh."),
+                /Type error for parameter arg1/,
+                "root.base.foo()");
+
+  Assert.throws(() => base.base.foo("Meh."),
+                /Type error for parameter arg1/,
+                "base.base.foo()");
+
+  Assert.throws(() => root.experiments.foo.foo("Meh."),
+                /Incorrect argument types/,
+                "root.experiments.foo.foo()");
+
+  Assert.throws(() => root.experiments.bar.bar("Meh."),
+                /Incorrect argument types/,
+                "root.experiments.bar.bar()");
+});
--- a/toolkit/components/extensions/test/xpcshell/xpcshell.ini
+++ b/toolkit/components/extensions/test/xpcshell/xpcshell.ini
@@ -35,16 +35,17 @@ tags = webextensions in-process-webexten
 [test_ext_contexts.js]
 [test_ext_json_parser.js]
 [test_ext_manifest_content_security_policy.js]
 [test_ext_manifest_incognito.js]
 [test_ext_manifest_minimum_chrome_version.js]
 [test_ext_manifest_minimum_opera_version.js]
 [test_ext_manifest_themes.js]
 [test_ext_schemas.js]
+[test_ext_schemas_roots.js]
 [test_ext_schemas_async.js]
 [test_ext_schemas_allowed_contexts.js]
 [test_ext_schemas_interactive.js]
 [test_ext_schemas_privileged.js]
 [test_ext_schemas_revoke.js]
 [test_ext_themes_supported_properties.js]
 [test_ext_unknown_permissions.js]
 [test_locale_converter.js]
--- a/tools/lint/eslint/modules.json
+++ b/tools/lint/eslint/modules.json
@@ -170,16 +170,17 @@
   "RemoteFinder.jsm": ["RemoteFinder", "RemoteFinderListener"],
   "RemotePageManager.jsm": ["RemotePages", "RemotePageManager", "PageListener"],
   "RemoteWebProgress.jsm": ["RemoteWebProgressManager"],
   "resource.js": ["AsyncResource", "Resource"],
   "rest.js": ["RESTRequest", "RESTResponse", "TokenAuthenticatedRESTRequest"],
   "rotaryengine.js": ["RotaryEngine", "RotaryRecord", "RotaryStore", "RotaryTracker"],
   "require.js": ["require"],
   "RTCStatsReport.jsm": ["convertToRTCStatsReport"],
+  "Schemas.jsm": ["SchemaRoot", "Schemas"],
   "scratchpad-manager.jsm": ["ScratchpadManager"],
   "server.js": ["server"],
   "service.js": ["Service"],
   "SharedPromptUtils.jsm": ["PromptUtils", "EnableDelayHelper"],
   "ShutdownLeaksCollector.jsm": ["ContentCollector"],
   "SignInToWebsite.jsm": ["SignInToWebsiteController"],
   "Social.jsm": ["Social", "OpenGraphBuilder", "DynamicResizeWatcher", "sizeSocialPanelToContent"],
   "SpecialPowersObserver.jsm": ["SpecialPowersObserver", "SpecialPowersObserverFactory"],