Add mechanism for plugins to modify rest api calls

A plugin can registers a rest api hook to add plugin params to core rest api
calls. The addParameterFunction only needs to return plugin specific params,
not the entire params object.

This will slow down the initial call to get changes and the change detail as
the xhr now waits for the plugins to finish loading. However once plugins are
loaded, the xhr will fire as quickly as before.

This is currently only supported in the changes query and change detail.

Change-Id: I894628663c6616152030d05e41a0008d1601af3f
diff --git a/Documentation/pg-plugin-dev.txt b/Documentation/pg-plugin-dev.txt
index 8fb5655..e7e5f73 100644
--- a/Documentation/pg-plugin-dev.txt
+++ b/Documentation/pg-plugin-dev.txt
@@ -185,6 +185,30 @@
 
 Note: TODO
 
+=== registerApiHook
+`plugin.registerApiHook(apiEndpointName, addParameterFunction)`
+
+Registers a rest api hook to add plugin params to core rest api calls.
+The addParameterFunction only needs to return plugin specific params, not the
+entire params object.
+
+Supported endpoints are `changes` and `change`.
+
+```
+Gerrit.install('my-plugin-name', plugin => {
+  plugin.restApiHooks().registerRestApiParams('changes', params => {
+    return 'my-plugin-option';
+  });
+});
+
+// will update the changes query params to be:
+
+{
+  // existing changes params
+  'my-plugin-name': 'my-plugin-option',
+}
+```
+
 === registerStyleModule
 `plugin.registerStyleModule(endpointName, moduleName)`
 
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.html
index d8a662e..3593253 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.html
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.html
@@ -38,5 +38,6 @@
   <script src="gr-plugin-endpoints.js"></script>
   <script src="gr-plugin-action-context.js"></script>
   <script src="gr-plugin-rest-api.js"></script>
+  <script src="gr-rest-api-hooks.js"></script>
   <script src="gr-public-js-api.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js
index b31c3f2..7047229 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js
@@ -319,6 +319,10 @@
     return new GrSettingsApi(this);
   };
 
+  Plugin.prototype.restApiHooks = function() {
+    return new GrRestApiHooks(this);
+  };
+
   /**
    * To make REST requests for plugin-provided endpoints, use
    * @example
@@ -626,6 +630,12 @@
     return _allPluginsPromise;
   };
 
+  // TODO: Remove this. This is a hack to get the tests to pass.
+  // It would be much better to call GrRestApiHooks.pluginParams directly.
+  Gerrit._pluginParams = function(endpointName, initialParams) {
+    return GrRestApiHooks.pluginParams(endpointName, initialParams);
+  };
+
   Gerrit._pluginLoadingTimeout = function() {
     console.error(`Failed to load plugins: ${Object.keys(_pluginsPending)}`);
     Gerrit._setPluginsPending([]);
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-rest-api-hooks.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-rest-api-hooks.js
new file mode 100644
index 0000000..4e9e211
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-rest-api-hooks.js
@@ -0,0 +1,92 @@
+/**
+ * @license
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+(function() {
+  'use strict';
+
+  // Prevent redefinition.
+  if (window.GrRestApiHooks) { return; }
+
+  // Stores a map of endpointNames and api instances to add parameters to
+  // REST API calls.
+  const _apiInstances = {};
+
+  function GrRestApiHooks(plugin) {
+    this.plugin = plugin;
+    // Stores a map of endpointNames and functions to add parameters to REST API
+    // calls.
+    this._addParameterFunctions = {};
+  }
+
+  /**
+   * Registers an api hook for a particular api endpoint.
+   * This is called by a plugin.
+   *
+   * @param {string} endpointName the name of the endpoint.
+   * @param {Function} addParameterFunction the function that returns params
+   * for the plugin. Takes in current params.
+   */
+  GrRestApiHooks.prototype.registerRestApiParams = function(endpointName,
+      addParameterFunction) {
+    if (this._addParameterFunctions[endpointName]) {
+      console.warn(`Rewriting rest api parameter function for
+        ${this.plugin.getPluginName()}`);
+    }
+    this._addParameterFunctions[endpointName] = addParameterFunction;
+    if (!_apiInstances[endpointName]) {
+      _apiInstances[endpointName] = [];
+    }
+    _apiInstances[endpointName].push(this);
+  };
+
+  /**
+   * Returns params for a registered api hook for a particular api endpoint or
+   * null.
+   * This is called by the application, not the plugin.
+   * It will either return params or null if there are no params.
+   * @param {string} endpointName the name of the endpoint.
+   * @param {!Object} initialParams the params of the rest api call.
+   */
+  GrRestApiHooks.prototype._getRestApiParams = function(endpointName,
+      initialParams) {
+    const addParameterFunction = this._addParameterFunctions[endpointName];
+    if (!addParameterFunction) return null;
+    return addParameterFunction(initialParams);
+  };
+
+  /**
+   * Gets the params for a particular mutation endpoint.
+   *
+   * This is called by the application and should not be called by plugins.
+   *
+   * @param {string} endpointName the name of the endpoint.
+   * @param {!Object} initialParams the params of the rest api call.
+   * @return new parameters to add to a REST API call.
+   */
+  GrRestApiHooks.pluginParams = function(endpointName, initialParams) {
+    return (_apiInstances[endpointName] || []).reduce((accum, apiInstance) => {
+      const pluginParams = apiInstance._getRestApiParams(
+          endpointName, initialParams);
+      if (pluginParams) {
+        accum[apiInstance.plugin.getPluginName()] =
+          JSON.stringify(pluginParams);
+      }
+      return accum;
+    }, {});
+  };
+
+  window.GrRestApiHooks = GrRestApiHooks;
+})(window);
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.html b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.html
index 562980c..4889472 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.html
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.html
@@ -30,5 +30,8 @@
   <!-- NB: Order is important, because of namespaced classes. -->
   <script src="gr-auth.js"></script>
   <script src="gr-reviewer-updates-parser.js"></script>
+  <script src="../gr-js-api-interface/gr-plugin-endpoints.js"></script>
+  <script src="../gr-js-api-interface/gr-rest-api-hooks.js"></script>
+  <script src="../gr-js-api-interface/gr-public-js-api.js"></script>
   <script src="gr-rest-api-interface.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
index 849972f..03a58074 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
@@ -1246,48 +1246,51 @@
      *     changeInfos.
      */
     getChanges(opt_changesPerPage, opt_query, opt_offset, opt_options) {
-      const options = opt_options || this.listChangesOptionsToHex(
-          this.ListChangesOption.LABELS,
-          this.ListChangesOption.DETAILED_ACCOUNTS
-      );
-      // Issue 4524: respect legacy token with max sortkey.
-      if (opt_offset === 'n,z') {
-        opt_offset = 0;
-      }
-      const params = {
-        O: options,
-        S: opt_offset || 0,
-      };
-      if (opt_changesPerPage) { params.n = opt_changesPerPage; }
-      if (opt_query && opt_query.length > 0) {
-        params.q = opt_query;
-      }
-      const iterateOverChanges = arr => {
-        for (const change of (arr || [])) {
-          this._maybeInsertInLookup(change);
+      return Gerrit.awaitPluginsLoaded().then(() => {
+        const options = opt_options || this.listChangesOptionsToHex(
+            this.ListChangesOption.LABELS,
+            this.ListChangesOption.DETAILED_ACCOUNTS
+        );
+        // Issue 4524: respect legacy token with max sortkey.
+        if (opt_offset === 'n,z') {
+          opt_offset = 0;
         }
-      };
-      const req = {
-        url: '/changes/',
-        params,
-        reportUrlAsIs: true,
-      };
-      return this._fetchJSON(req).then(response => {
-        // Response may be an array of changes OR an array of arrays of
-        // changes.
-        if (opt_query instanceof Array) {
-          // Normalize the response to look like a multi-query response
-          // when there is only one query.
-          if (opt_query.length === 1) {
-            response = [response];
-          }
-          for (const arr of response) {
-            iterateOverChanges(arr);
-          }
-        } else {
-          iterateOverChanges(response);
+        const params = {
+          O: options,
+          S: opt_offset || 0,
+        };
+        if (opt_changesPerPage) { params.n = opt_changesPerPage; }
+        if (opt_query && opt_query.length > 0) {
+          params.q = opt_query;
         }
-        return response;
+        const iterateOverChanges = arr => {
+          for (const change of (arr || [])) {
+            this._maybeInsertInLookup(change);
+          }
+        };
+        Object.assign(params, Gerrit._pluginParams('changes', params));
+        const req = {
+          url: '/changes/',
+          params,
+          reportUrlAsIs: true,
+        };
+        return this._fetchJSON(req).then(response => {
+          // Response may be an array of changes OR an array of arrays of
+          // changes.
+          if (opt_query instanceof Array) {
+            // Normalize the response to look like a multi-query response
+            // when there is only one query.
+            if (opt_query.length === 1) {
+              response = [response];
+            }
+            for (const arr of response) {
+              iterateOverChanges(arr);
+            }
+          } else {
+            iterateOverChanges(response);
+          }
+          return response;
+        });
       });
     },
 
@@ -1368,9 +1371,15 @@
      * @param {function()=} opt_cancelCondition
      */
     _getChangeDetail(changeNum, optionsHex, opt_errFn, opt_cancelCondition) {
-      return this.getChangeActionURL(changeNum, null, '/detail').then(url => {
+      return Promise.all([
+        Gerrit.awaitPluginsLoaded(),
+        this.getChangeActionURL(changeNum, null, '/detail'),
+      ]).then(([_, url]) => {
         const urlWithParams = this._urlWithParams(url, optionsHex);
+
         const params = {O: optionsHex};
+        Object.assign(params, Gerrit._pluginParams('change', params));
+
         const req = {
           url,
           errFn: opt_errFn,
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html
index ef4e401..a8b00c6 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html
@@ -55,6 +55,8 @@
           return Promise.resolve(testJSON);
         },
       }));
+      sandbox.stub(Gerrit, 'awaitPluginsLoaded').returns(
+          Promise.resolve(true));
     });
 
     teardown(() => {
@@ -522,11 +524,13 @@
           });
     });
 
-    test('legacy n,z key in change url is replaced', () => {
+    test('legacy n,z key in change url is replaced', done => {
       const stub = sandbox.stub(element, '_fetchJSON')
           .returns(Promise.resolve([]));
-      element.getChanges(1, null, 'n,z');
-      assert.equal(stub.lastCall.args[0].params.S, 0);
+      element.getChanges(1, null, 'n,z').then(() => {
+        assert.equal(stub.lastCall.args[0].params.S, 0);
+        done();
+      }, done);
     });
 
     test('saveDiffPreferences invalidates cache line', () => {