Merge changes from topic "gr-rest-api-helper-to-ts"

* changes:
  Convert gr-rest-api-helper to typescript
  Rename files to preserve history
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.js
deleted file mode 100644
index f0932b6..0000000
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.js
+++ /dev/null
@@ -1,404 +0,0 @@
-/**
- * @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.
- */
-import {getBaseUrl} from '../../../../utils/url-util.js';
-
-const JSON_PREFIX = ')]}\'';
-
-/**
- * Wrapper around Map for caching server responses. Site-based so that
- * changes to CANONICAL_PATH will result in a different cache going into
- * effect.
- */
-export class SiteBasedCache {
-  constructor() {
-    // Container of per-canonical-path caches.
-    this._data = new Map();
-    if (window.INITIAL_DATA != undefined) {
-      // Put all data shipped with index.html into the cache. This makes it
-      // so that we spare more round trips to the server when the app loads
-      // initially.
-      Object
-          .entries(window.INITIAL_DATA)
-          .forEach(e => this._cache().set(e[0], e[1]));
-    }
-  }
-
-  // Returns the cache for the current canonical path.
-  _cache() {
-    if (!this._data.has(window.CANONICAL_PATH)) {
-      this._data.set(window.CANONICAL_PATH, new Map());
-    }
-    return this._data.get(window.CANONICAL_PATH);
-  }
-
-  has(key) {
-    return this._cache().has(key);
-  }
-
-  get(key) {
-    return this._cache().get(key);
-  }
-
-  set(key, value) {
-    this._cache().set(key, value);
-  }
-
-  delete(key) {
-    this._cache().delete(key);
-  }
-
-  invalidatePrefix(prefix) {
-    const newMap = new Map();
-    for (const [key, value] of this._cache().entries()) {
-      if (!key.startsWith(prefix)) {
-        newMap.set(key, value);
-      }
-    }
-    this._data.set(window.CANONICAL_PATH, newMap);
-  }
-}
-
-export class FetchPromisesCache {
-  constructor() {
-    this._data = {};
-  }
-
-  has(key) {
-    return !!this._data[key];
-  }
-
-  get(key) {
-    return this._data[key];
-  }
-
-  set(key, value) {
-    this._data[key] = value;
-  }
-
-  invalidatePrefix(prefix) {
-    const newData = {};
-    Object.entries(this._data).forEach(([key, value]) => {
-      if (!key.startsWith(prefix)) {
-        newData[key] = value;
-      }
-    });
-    this._data = newData;
-  }
-}
-
-export class GrRestApiHelper {
-  /**
-   * @param {SiteBasedCache} cache
-   * @param {object} auth
-   * @param {FetchPromisesCache} fetchPromisesCache
-   * @param {object} restApiInterface
-   */
-  constructor(cache, auth, fetchPromisesCache,
-      restApiInterface) {
-    this._cache = cache;// TODO: make it public
-    this._auth = auth;
-    this._fetchPromisesCache = fetchPromisesCache;
-    this._restApiInterface = restApiInterface;
-  }
-
-  /**
-   * Wraps calls to the underlying authenticated fetch function (_auth.fetch)
-   * with timing and logging.
-   *
-   * @param {Gerrit.FetchRequest} req
-   */
-  fetch(req) {
-    const start = Date.now();
-    const xhr = this._auth.fetch(req.url, req.fetchOptions);
-
-    // Log the call after it completes.
-    xhr.then(res => this._logCall(req, start, res ? res.status : null));
-
-    // Return the XHR directly (without the log).
-    return xhr;
-  }
-
-  /**
-   * Log information about a REST call. Because the elapsed time is determined
-   * by this method, it should be called immediately after the request
-   * finishes.
-   *
-   * @param {Gerrit.FetchRequest} req
-   * @param {number} startTime the time that the request was started.
-   * @param {number} status the HTTP status of the response. The status value
-   *     is used here rather than the response object so there is no way this
-   *     method can read the body stream.
-   */
-  _logCall(req, startTime, status) {
-    const method = (req.fetchOptions && req.fetchOptions.method) ?
-      req.fetchOptions.method : 'GET';
-    const endTime = Date.now();
-    const elapsed = (endTime - startTime);
-    const startAt = new Date(startTime);
-    const endAt = new Date(endTime);
-    console.info([
-      'HTTP',
-      status,
-      method,
-      elapsed + 'ms',
-      req.anonymizedUrl || req.url,
-      `(${startAt.toISOString()}, ${endAt.toISOString()})`,
-    ].join(' '));
-    if (req.anonymizedUrl) {
-      this.dispatchEvent(new CustomEvent('rpc-log', {
-        detail: {status, method, elapsed, anonymizedUrl: req.anonymizedUrl},
-        composed: true, bubbles: true,
-      }));
-    }
-  }
-
-  /**
-   * Fetch JSON from url provided.
-   * Returns a Promise that resolves to a native Response.
-   * Doesn't do error checking. Supports cancel condition. Performs auth.
-   * Validates auth expiry errors.
-   *
-   * @param {Gerrit.FetchJSONRequest} req
-   */
-  fetchRawJSON(req) {
-    const urlWithParams = this.urlWithParams(req.url, req.params);
-    const fetchReq = {
-      url: urlWithParams,
-      fetchOptions: req.fetchOptions,
-      anonymizedUrl: req.reportUrlAsIs ? urlWithParams : req.anonymizedUrl,
-    };
-    return this.fetch(fetchReq)
-        .then(res => {
-          if (req.cancelCondition && req.cancelCondition()) {
-            res.body.cancel();
-            return;
-          }
-          return res;
-        })
-        .catch(err => {
-          if (req.errFn) {
-            req.errFn.call(undefined, null, err);
-          } else {
-            this.dispatchEvent(new CustomEvent('network-error', {
-              detail: {error: err},
-              composed: true, bubbles: true,
-            }));
-          }
-          throw err;
-        });
-  }
-
-  /**
-   * Fetch JSON from url provided.
-   * Returns a Promise that resolves to a parsed response.
-   * Same as {@link fetchRawJSON}, plus error handling.
-   *
-   * @param {Gerrit.FetchJSONRequest} req
-   * @param {boolean} noAcceptHeader - don't add default accept json header
-   */
-  fetchJSON(req, noAcceptHeader) {
-    if (!noAcceptHeader) {
-      req = this.addAcceptJsonHeader(req);
-    }
-    return this.fetchRawJSON(req).then(response => {
-      if (!response) {
-        return;
-      }
-      if (!response.ok) {
-        if (req.errFn) {
-          req.errFn.call(null, response);
-          return;
-        }
-        this.dispatchEvent(new CustomEvent('server-error', {
-          detail: {request: req, response},
-          composed: true, bubbles: true,
-        }));
-        return;
-      }
-      return response && this.getResponseObject(response);
-    });
-  }
-
-  /**
-   * @param {string} url
-   * @param {?Object|string=} opt_params URL params, key-value hash.
-   * @return {string}
-   */
-  urlWithParams(url, opt_params) {
-    if (!opt_params) { return getBaseUrl() + url; }
-
-    const params = [];
-    for (const p in opt_params) {
-      if (!opt_params.hasOwnProperty(p)) { continue; }
-      if (opt_params[p] == null) {
-        params.push(encodeURIComponent(p));
-        continue;
-      }
-      for (const value of [].concat(opt_params[p])) {
-        params.push(`${encodeURIComponent(p)}=${encodeURIComponent(value)}`);
-      }
-    }
-    return getBaseUrl() + url + '?' + params.join('&');
-  }
-
-  /**
-   * @param {!Object} response
-   * @return {?}
-   */
-  getResponseObject(response) {
-    return this.readResponsePayload(response)
-        .then(payload => payload.parsed);
-  }
-
-  /**
-   * @param {!Object} response
-   * @return {!Object}
-   */
-  readResponsePayload(response) {
-    return response.text().then(text => {
-      let result;
-      try {
-        result = this.parsePrefixedJSON(text);
-      } catch (_) {
-        result = null;
-      }
-      return {parsed: result, raw: text};
-    });
-  }
-
-  /**
-   * @param {string} source
-   * @return {?}
-   */
-  parsePrefixedJSON(source) {
-    return JSON.parse(source.substring(JSON_PREFIX.length));
-  }
-
-  /**
-   * @param {Gerrit.FetchJSONRequest} req
-   * @return {Gerrit.FetchJSONRequest}
-   */
-  addAcceptJsonHeader(req) {
-    if (!req.fetchOptions) req.fetchOptions = {};
-    if (!req.fetchOptions.headers) req.fetchOptions.headers = new Headers();
-    if (!req.fetchOptions.headers.has('Accept')) {
-      req.fetchOptions.headers.append('Accept', 'application/json');
-    }
-    return req;
-  }
-
-  dispatchEvent(type, detail) {
-    return this._restApiInterface.dispatchEvent(type, detail);
-  }
-
-  /**
-   * @param {Gerrit.FetchJSONRequest} req
-   */
-  fetchCacheURL(req) {
-    if (this._fetchPromisesCache.has(req.url)) {
-      return this._fetchPromisesCache.get(req.url);
-    }
-    // TODO(andybons): Periodic cache invalidation.
-    if (this._cache.has(req.url)) {
-      return Promise.resolve(this._cache.get(req.url));
-    }
-    this._fetchPromisesCache.set(req.url,
-        this.fetchJSON(req)
-            .then(response => {
-              if (response !== undefined) {
-                this._cache.set(req.url, response);
-              }
-              this._fetchPromisesCache.set(req.url, undefined);
-              return response;
-            })
-            .catch(err => {
-              this._fetchPromisesCache.set(req.url, undefined);
-              throw err;
-            })
-    );
-    return this._fetchPromisesCache.get(req.url);
-  }
-
-  /**
-   * Send an XHR.
-   *
-   * @param {Gerrit.SendRequest} req
-   * @return {Promise}
-   */
-  send(req) {
-    const options = {method: req.method};
-    if (req.body) {
-      options.headers = new Headers();
-      options.headers.set(
-          'Content-Type', req.contentType || 'application/json');
-      options.body = typeof req.body === 'string' ?
-        req.body : JSON.stringify(req.body);
-    }
-    if (req.headers) {
-      if (!options.headers) { options.headers = new Headers(); }
-      for (const header in req.headers) {
-        if (!req.headers.hasOwnProperty(header)) { continue; }
-        options.headers.set(header, req.headers[header]);
-      }
-    }
-    const url = req.url.startsWith('http') ?
-      req.url : getBaseUrl() + req.url;
-    const fetchReq = {
-      url,
-      fetchOptions: options,
-      anonymizedUrl: req.reportUrlAsIs ? url : req.anonymizedUrl,
-    };
-    const xhr = this.fetch(fetchReq)
-        .then(response => {
-          if (!response.ok) {
-            if (req.errFn) {
-              return req.errFn.call(undefined, response);
-            }
-            this.dispatchEvent(new CustomEvent('server-error', {
-              detail: {request: fetchReq, response},
-              composed: true, bubbles: true,
-            }));
-          }
-          return response;
-        })
-        .catch(err => {
-          this.dispatchEvent(new CustomEvent('network-error', {
-            detail: {error: err},
-            composed: true, bubbles: true,
-          }));
-          if (req.errFn) {
-            return req.errFn.call(undefined, null, err);
-          } else {
-            throw err;
-          }
-        });
-
-    if (req.parseResponse) {
-      return xhr.then(res => this.getResponseObject(res));
-    }
-
-    return xhr;
-  }
-
-  /**
-   * @param {string} prefix
-   */
-  invalidateFetchPromisesPrefix(prefix) {
-    this._fetchPromisesCache.invalidatePrefix(prefix);
-    this._cache.invalidatePrefix(prefix);
-  }
-}
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts
new file mode 100644
index 0000000..ad1c864
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts
@@ -0,0 +1,497 @@
+/**
+ * @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.
+ */
+import {getBaseUrl} from '../../../../utils/url-util';
+import {
+  ErrorCallback,
+  RestApiService,
+} from '../../../../services/services/gr-rest-api/gr-rest-api';
+import {
+  AuthRequestInit,
+  AuthService,
+} from '../../../../services/gr-auth/gr-auth';
+import {hasOwnProperty} from '../../../../utils/common-util';
+import {HttpMethod} from '../../../../types/common';
+
+const JSON_PREFIX = ")]}'";
+
+/**
+ * Wrapper around Map for caching server responses. Site-based so that
+ * changes to CANONICAL_PATH will result in a different cache going into
+ * effect.
+ */
+export class SiteBasedCache {
+  // TODO(TS): Type looks unusual. Fix it.
+  // Container of per-canonical-path caches.
+  private readonly _data = new Map<
+    string | undefined,
+    unknown | Map<string, ParsedJSON>
+  >();
+
+  constructor() {
+    if (window.INITIAL_DATA) {
+      // Put all data shipped with index.html into the cache. This makes it
+      // so that we spare more round trips to the server when the app loads
+      // initially.
+      Object.entries(window.INITIAL_DATA).forEach(e =>
+        this._cache().set(e[0], e[1])
+      );
+    }
+  }
+
+  // Returns the cache for the current canonical path.
+  _cache(): Map<string, ParsedJSON> {
+    if (!this._data.has(window.CANONICAL_PATH)) {
+      this._data.set(window.CANONICAL_PATH, new Map());
+    }
+    return this._data.get(window.CANONICAL_PATH) as Map<string, ParsedJSON>;
+  }
+
+  has(key: string) {
+    return this._cache().has(key);
+  }
+
+  get(key: string) {
+    return this._cache().get(key);
+  }
+
+  set(key: string, value: ParsedJSON) {
+    this._cache().set(key, value);
+  }
+
+  delete(key: string) {
+    this._cache().delete(key);
+  }
+
+  invalidatePrefix(prefix: string) {
+    const newMap = new Map();
+    for (const [key, value] of this._cache().entries()) {
+      if (!key.startsWith(prefix)) {
+        newMap.set(key, value);
+      }
+    }
+    this._data.set(window.CANONICAL_PATH, newMap);
+  }
+}
+
+/**
+ * Type alias for parsed json object to make code cleaner
+ */
+export type ParsedJSON = unknown;
+
+type FetchPromisesCacheData = {[url: string]: Promise<ParsedJSON> | undefined};
+
+export class FetchPromisesCache {
+  private _data: FetchPromisesCacheData;
+
+  constructor() {
+    this._data = {};
+  }
+
+  /**
+   * @return true only if a value for a key sets and it is not undefined
+   */
+  has(key: string): boolean {
+    return !!this._data[key];
+  }
+
+  get(key: string) {
+    return this._data[key];
+  }
+
+  /**
+   * @param value a Promise to store in the cache. Pass undefined value to
+   *     mark key as deleted.
+   */
+  set(key: string, value: Promise<ParsedJSON> | undefined) {
+    this._data[key] = value;
+  }
+
+  invalidatePrefix(prefix: string) {
+    const newData: FetchPromisesCacheData = {};
+    Object.entries(this._data).forEach(([key, value]) => {
+      if (!key.startsWith(prefix)) {
+        newData[key] = value;
+      }
+    });
+    this._data = newData;
+  }
+}
+export type FetchParams = {
+  [name: string]: string | number | boolean | undefined | null;
+};
+
+interface SendRequestBase {
+  method: HttpMethod;
+  body: string | object;
+  contentType?: string;
+  headers: Record<string, string>;
+  url: string;
+  reportUrlAsIs?: boolean;
+  anonymizedUrl?: string;
+  errFn?: ErrorCallback;
+}
+
+export interface SendRawRequest extends SendRequestBase {
+  parseResponse?: false | null;
+}
+
+export interface SendJSONRequest extends SendRequestBase {
+  parseResponse: true;
+}
+
+export type SendRequest = SendRawRequest | SendJSONRequest;
+
+export interface FetchRequest {
+  url: string;
+  fetchOptions: AuthRequestInit;
+  anonymizedUrl?: string;
+}
+
+export interface FetchJSONRequest extends FetchRequest {
+  reportUrlAsIs?: boolean;
+  cancelCondition?: () => boolean;
+  errFn: ErrorCallback;
+  params: FetchParams;
+}
+
+export class GrRestApiHelper {
+  constructor(
+    private readonly _cache: SiteBasedCache,
+    private readonly _auth: AuthService,
+    private readonly _fetchPromisesCache: FetchPromisesCache,
+    private readonly _restApiInterface: RestApiService
+  ) {}
+
+  /**
+   * Wraps calls to the underlying authenticated fetch function (_auth.fetch)
+   * with timing and logging.
+s   */
+  fetch(req: FetchRequest): Promise<Response> {
+    const start = Date.now();
+    const xhr = this._auth.fetch(req.url, req.fetchOptions);
+
+    // Log the call after it completes.
+    xhr.then(res => this._logCall(req, start, res ? res.status : null));
+
+    // Return the XHR directly (without the log).
+    return xhr;
+  }
+
+  /**
+   * Log information about a REST call. Because the elapsed time is determined
+   * by this method, it should be called immediately after the request
+   * finishes.
+   *
+   * @param startTime the time that the request was started.
+   * @param status the HTTP status of the response. The status value
+   *     is used here rather than the response object so there is no way this
+   *     method can read the body stream.
+   */
+  private _logCall(
+    req: FetchRequest,
+    startTime: number,
+    status: number | null
+  ) {
+    const method =
+      req.fetchOptions && req.fetchOptions.method
+        ? req.fetchOptions.method
+        : 'GET';
+    const endTime = Date.now();
+    const elapsed = endTime - startTime;
+    const startAt = new Date(startTime);
+    const endAt = new Date(endTime);
+    console.info(
+      [
+        'HTTP',
+        status,
+        method,
+        `${elapsed}ms`,
+        req.anonymizedUrl || req.url,
+        `(${startAt.toISOString()}, ${endAt.toISOString()})`,
+      ].join(' ')
+    );
+    if (req.anonymizedUrl) {
+      this.dispatchEvent(
+        new CustomEvent('rpc-log', {
+          detail: {status, method, elapsed, anonymizedUrl: req.anonymizedUrl},
+          composed: true,
+          bubbles: true,
+        })
+      );
+    }
+  }
+
+  /**
+   * Fetch JSON from url provided.
+   * Returns a Promise that resolves to a native Response.
+   * Doesn't do error checking. Supports cancel condition. Performs auth.
+   * Validates auth expiry errors.
+   *
+   * @return Promise which resolves to undefined if cancelCondition returns true
+   *     and resolves to Response otherwise
+   */
+  fetchRawJSON(req: FetchJSONRequest): Promise<Response | undefined> {
+    const urlWithParams = this.urlWithParams(req.url, req.params);
+    const fetchReq: FetchRequest = {
+      url: urlWithParams,
+      fetchOptions: req.fetchOptions,
+      anonymizedUrl: req.reportUrlAsIs ? urlWithParams : req.anonymizedUrl,
+    };
+    return this.fetch(fetchReq)
+      .then((res: Response) => {
+        if (req.cancelCondition && req.cancelCondition()) {
+          if (res.body) {
+            res.body.cancel();
+          }
+          return;
+        }
+        return res;
+      })
+      .catch(err => {
+        if (req.errFn) {
+          req.errFn.call(undefined, null, err);
+        } else {
+          this.dispatchEvent(
+            new CustomEvent('network-error', {
+              detail: {error: err},
+              composed: true,
+              bubbles: true,
+            })
+          );
+        }
+        throw err;
+      });
+  }
+
+  /**
+   * Fetch JSON from url provided.
+   * Returns a Promise that resolves to a parsed response.
+   * Same as {@link fetchRawJSON}, plus error handling.
+   *
+   * @param noAcceptHeader - don't add default accept json header
+   */
+  fetchJSON(
+    req: FetchJSONRequest,
+    noAcceptHeader?: boolean
+  ): Promise<ParsedJSON> {
+    if (!noAcceptHeader) {
+      req = this.addAcceptJsonHeader(req);
+    }
+    return this.fetchRawJSON(req).then(response => {
+      if (!response) {
+        return;
+      }
+      if (!response.ok) {
+        if (req.errFn) {
+          req.errFn.call(null, response);
+          return;
+        }
+        this.dispatchEvent(
+          new CustomEvent('server-error', {
+            detail: {request: req, response},
+            composed: true,
+            bubbles: true,
+          })
+        );
+        return;
+      }
+      return response && this.getResponseObject(response);
+    });
+  }
+
+  urlWithParams(url: string, fetchParams?: FetchParams): string {
+    if (!fetchParams) {
+      return getBaseUrl() + url;
+    }
+
+    const params: Array<string | number | boolean> = [];
+    for (const p in fetchParams) {
+      if (!hasOwnProperty(fetchParams, p)) {
+        continue;
+      }
+      const paramValue = fetchParams[p];
+      // TODO(TS): Replace == null with === and check for null and undefined
+      // eslint-disable-next-line eqeqeq
+      if (paramValue == null) {
+        params.push(encodeURIComponent(p));
+        continue;
+      }
+      // TODO(TS): Unclear, why do we need the following code.
+      // If paramValue can be array - we should either fix FetchParams type
+      // or convert the array to a string before calling urlWithParams method.
+      const paramValueAsArray = ([] as Array<string | number | boolean>).concat(
+        paramValue
+      );
+      for (const value of paramValueAsArray) {
+        params.push(`${encodeURIComponent(p)}=${encodeURIComponent(value)}`);
+      }
+    }
+    return getBaseUrl() + url + '?' + params.join('&');
+  }
+
+  getResponseObject(response: Response): ParsedJSON {
+    return this.readResponsePayload(response).then(payload => payload.parsed);
+  }
+
+  readResponsePayload(
+    response: Response
+  ): Promise<{parsed: ParsedJSON | string; raw: string}> {
+    return response.text().then(text => {
+      let result;
+      try {
+        result = this.parsePrefixedJSON(text);
+      } catch (_) {
+        result = null;
+      }
+      return {parsed: result, raw: text};
+    });
+  }
+
+  parsePrefixedJSON(jsonWithPrefix: string): ParsedJSON {
+    return JSON.parse(
+      jsonWithPrefix.substring(JSON_PREFIX.length)
+    ) as ParsedJSON;
+  }
+
+  addAcceptJsonHeader(req: FetchJSONRequest) {
+    if (!req.fetchOptions) req.fetchOptions = {};
+    if (!req.fetchOptions.headers) req.fetchOptions.headers = new Headers();
+    if (!req.fetchOptions.headers.has('Accept')) {
+      req.fetchOptions.headers.append('Accept', 'application/json');
+    }
+    return req;
+  }
+
+  dispatchEvent(type: Event, detail?: unknown): boolean {
+    return this._restApiInterface.dispatchEvent(type, detail);
+  }
+
+  fetchCacheURL(req: FetchJSONRequest): Promise<ParsedJSON> {
+    if (this._fetchPromisesCache.has(req.url)) {
+      return this._fetchPromisesCache.get(req.url)!;
+    }
+    // TODO(andybons): Periodic cache invalidation.
+    if (this._cache.has(req.url)) {
+      return Promise.resolve(this._cache.get(req.url));
+    }
+    this._fetchPromisesCache.set(
+      req.url,
+      this.fetchJSON(req)
+        .then((response: ParsedJSON) => {
+          if (response !== undefined) {
+            this._cache.set(req.url, response);
+          }
+          this._fetchPromisesCache.set(req.url, undefined);
+          return response;
+        })
+        .catch(err => {
+          this._fetchPromisesCache.set(req.url, undefined);
+          throw err;
+        })
+    );
+    return this._fetchPromisesCache.get(req.url)!;
+  }
+
+  /**
+   * @return Promise resolves to Response only if the request is successful
+   *     (i.e. no exception and response.ok is true). If response fails then
+   *     promise resolves either to void if errFn is set or rejects if errFn
+   *     is not set
+   */
+  send(req: SendRawRequest): Promise<Response | void>;
+
+  send(req: SendJSONRequest): Promise<ParsedJSON>;
+
+  /**
+   * Send an XHR.
+   */
+  send(req: SendRequest) {
+    const options: AuthRequestInit = {method: req.method};
+    if (req.body) {
+      options.headers = new Headers();
+      options.headers.set(
+        'Content-Type',
+        req.contentType || 'application/json'
+      );
+      options.body =
+        typeof req.body === 'string' ? req.body : JSON.stringify(req.body);
+    }
+    if (req.headers) {
+      if (!options.headers) {
+        options.headers = new Headers();
+      }
+      for (const header in req.headers) {
+        if (!hasOwnProperty(req.headers, header)) {
+          continue;
+        }
+        options.headers.set(header, req.headers[header]);
+      }
+    }
+    const url = req.url.startsWith('http') ? req.url : getBaseUrl() + req.url;
+    const fetchReq: FetchRequest = {
+      url,
+      fetchOptions: options,
+      anonymizedUrl: req.reportUrlAsIs ? url : req.anonymizedUrl,
+    };
+    const xhr = this.fetch(fetchReq)
+      .then(response => {
+        if (!response.ok) {
+          if (req.errFn) {
+            return req.errFn.call(undefined, response);
+          }
+          this.dispatchEvent(
+            new CustomEvent('server-error', {
+              detail: {request: fetchReq, response},
+              composed: true,
+              bubbles: true,
+            })
+          );
+        }
+        return response;
+      })
+      .catch(err => {
+        this.dispatchEvent(
+          new CustomEvent('network-error', {
+            detail: {error: err},
+            composed: true,
+            bubbles: true,
+          })
+        );
+        if (req.errFn) {
+          return req.errFn.call(undefined, null, err);
+        } else {
+          throw err;
+        }
+      });
+
+    if (req.parseResponse) {
+      // TODO(TS): remove as Response and fix error.
+      // Javascript code allows returning of a Response object from errFn.
+      // This can be a mistake and we should add check here or it can be used
+      // somewhere - in this case we should fix it carefully (define
+      // different type of callback if parseResponse is true, etc...).
+      return xhr.then(res => this.getResponseObject(res as Response));
+    }
+
+    return xhr;
+  }
+
+  invalidateFetchPromisesPrefix(prefix: string) {
+    this._fetchPromisesCache.invalidatePrefix(prefix);
+    this._cache.invalidatePrefix(prefix);
+  }
+}
diff --git a/polygerrit-ui/app/services/services/gr-rest-api/gr-rest-api.ts b/polygerrit-ui/app/services/services/gr-rest-api/gr-rest-api.ts
index e1f0ffc..7ed6f24 100644
--- a/polygerrit-ui/app/services/services/gr-rest-api/gr-rest-api.ts
+++ b/polygerrit-ui/app/services/services/gr-rest-api/gr-rest-api.ts
@@ -25,7 +25,7 @@
   ProjectInfo,
 } from '../../../types/common';
 
-export type ErrorCallback = (response?: Response, err?: Error) => void;
+export type ErrorCallback = (response?: Response | null, err?: Error) => void;
 
 /**
  * Contains information about an account that can be added to a change
@@ -63,6 +63,9 @@
   | SuggestedReviewerGroupInfo;
 
 export interface RestApiService {
+  // TODO(TS): unclear what is a second parameter. Looks like it is a mistake
+  // and it must be removed
+  dispatchEvent(event: Event, detail?: unknown): boolean;
   getConfig(): Promise<ServerInfo>;
   getLoggedIn(): Promise<boolean>;
   getVersion(): Promise<string>;
diff --git a/polygerrit-ui/app/types/globals.ts b/polygerrit-ui/app/types/globals.ts
index 3cf6622..aa8ebd3 100644
--- a/polygerrit-ui/app/types/globals.ts
+++ b/polygerrit-ui/app/types/globals.ts
@@ -19,6 +19,7 @@
 declare global {
   interface Window {
     CANONICAL_PATH?: string;
+    INITIAL_DATA?: {[key: string]: string};
     ShadyCSS?: {
       getComputedStyleValue(el: Element, name: string): string;
     };