Refactor gr-rest-api-interface - extract common methods to helper
gr-rest-api-interface contains api for different entities types
and all the code is placed in one file - gr-rest-api-interface.js.
This commit moves all utils methods to a separate GrRestApiHelper
class. After moving, new API can be added as a separate file.
In the future existing API can be refactored as well to provide more
structured access to API. For example, repo API can be exposed as
restAPI.repositories.getConfig(...)
Change-Id: I57f52847aa7e0d4f924f3f5105fc64cd0226d2ff
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 87ea02b..7461ac4 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
@@ -29,6 +29,7 @@
<dom-module id="gr-rest-api-interface">
<!-- NB: Order is important, because of namespaced classes. -->
+ <script src="gr-rest-apis/gr-rest-api-helper.js"></script>
<script src="gr-auth.js"></script>
<script src="gr-reviewer-updates-parser.js"></script>
<script src="gr-rest-api-interface.js"></script>
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 9b63f75..d634f14 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
@@ -29,34 +29,6 @@
/**
* @typedef {{
- * url: string,
- * fetchOptions: (Object|null|undefined),
- * anonymizedUrl: (string|undefined),
- * }}
- */
- Defs.FetchRequest;
-
- /**
- * Object to describe a request for passing into _fetchJSON or _fetchRawJSON.
- * - url is the URL for the request (excluding get params)
- * - errFn is a function to invoke when the request fails.
- * - cancelCondition is a function that, if provided and returns true, will
- * cancel the response after it resolves.
- * - params is a key-value hash to specify get params for the request URL.
- * @typedef {{
- * url: string,
- * errFn: (function(?Response, string=)|null|undefined),
- * cancelCondition: (function()|null|undefined),
- * params: (Object|null|undefined),
- * fetchOptions: (Object|null|undefined),
- * anonymizedUrl: (string|undefined),
- * reportUrlAsIs: (boolean|undefined),
- * }}
- */
- Defs.FetchJSONRequest;
-
- /**
- * @typedef {{
* changeNum: (string|number),
* endpoint: string,
* patchNum: (string|number|null|undefined),
@@ -121,7 +93,6 @@
const MAX_PROJECT_RESULTS = 25;
const MAX_UNIFIED_DEFAULT_WINDOW_WIDTH_PX = 900;
const PARENT_PATCH_NUM = 'PARENT';
- const FAILED_TO_FETCH_ERROR = 'Failed to fetch';
const Requests = {
SEND_DIFF_DRAFT: 'sendDiffDraft',
@@ -135,60 +106,6 @@
const ANONYMIZED_REVISION_BASE_URL = ANONYMIZED_CHANGE_BASE_URL +
'/revisions/*';
- /**
- * Wrapper around Map for caching server responses. Site-based so that
- * changes to CANONICAL_PATH will result in a different cache going into
- * effect.
- */
- 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);
- }
- }
-
Polymer({
is: 'gr-rest-api-interface',
_legacyUndefinedCheck: true,
@@ -235,7 +152,7 @@
},
_sharedFetchPromises: {
type: Object,
- value: {}, // Intentional to share the object across instances.
+ value: new FetchPromisesCache(), // Shared across instances.
},
_pendingRequests: {
type: Object,
@@ -259,133 +176,14 @@
},
JSON_PREFIX,
-
- /**
- * Wraps calls to the underlying authenticated fetch function (_auth.fetch)
- * with timing and logging.
- * @param {Defs.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.status));
-
- // Return the XHR directly (without the log).
- return xhr;
+ ready() {
+ this._restApiHelper = new GrRestApiHelper(this._cache, this._auth,
+ this._sharedFetchPromises, this._credentialCheck, this);
},
- /**
- * 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 {Defs.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.log([
- 'HTTP',
- status,
- method,
- elapsed + 'ms',
- req.anonymizedUrl || req.url,
- `(${startAt.toISOString()}, ${endAt.toISOString()})`,
- ].join(' '));
- if (req.anonymizedUrl) {
- this.fire('rpc-log',
- {status, method, elapsed, anonymizedUrl: req.anonymizedUrl});
- }
- },
-
- /**
- * 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 {Defs.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 => {
- const isLoggedIn = !!this._cache.get('/accounts/self/detail');
- if (isLoggedIn && err && err.message === FAILED_TO_FETCH_ERROR) {
- this.checkCredentials();
- } else {
- if (req.errFn) {
- req.errFn.call(undefined, null, err);
- } else {
- this.fire('network-error', {error: err});
- }
- }
- throw err;
- });
- },
-
- /**
- * Fetch JSON from url provided.
- * Returns a Promise that resolves to a parsed response.
- * Same as {@link _fetchRawJSON}, plus error handling.
- * @param {Defs.FetchJSONRequest} req
- */
- _fetchJSON(req) {
- 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.fire('server-error', {request: req, response});
- 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 this.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 this.getBaseUrl() + url + '?' + params.join('&');
+ _fetchSharedCacheURL(req) {
+ // Cache is shared across instances
+ return this._restApiHelper.fetchCacheURL(req);
},
/**
@@ -393,45 +191,7 @@
* @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 {Defs.FetchJSONRequest} req
- * @return {Defs.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;
+ return this._restApiHelper.getResponseObject(response);
},
getConfig(noCache) {
@@ -442,7 +202,7 @@
});
}
- return this._fetchJSON({
+ return this._restApiHelper.fetchJSON({
url: '/config/server/info',
reportUrlAsIs: true,
});
@@ -492,7 +252,7 @@
// supports it.
const url = `/projects/${encodeURIComponent(repo)}/config`;
this._cache.delete(url);
- return this._send({
+ return this._restApiHelper.send({
method: 'PUT',
url,
body: config,
@@ -506,7 +266,7 @@
// TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
// supports it.
const encodeName = encodeURIComponent(repo);
- return this._send({
+ return this._restApiHelper.send({
method: 'POST',
url: `/projects/${encodeName}/gc`,
body: '',
@@ -524,7 +284,7 @@
// TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
// supports it.
const encodeName = encodeURIComponent(config.name);
- return this._send({
+ return this._restApiHelper.send({
method: 'PUT',
url: `/projects/${encodeName}`,
body: config,
@@ -540,7 +300,7 @@
createGroup(config, opt_errFn) {
if (!config.name) { return ''; }
const encodeName = encodeURIComponent(config.name);
- return this._send({
+ return this._restApiHelper.send({
method: 'PUT',
url: `/groups/${encodeName}`,
body: config,
@@ -550,7 +310,7 @@
},
getGroupConfig(group, opt_errFn) {
- return this._fetchJSON({
+ return this._restApiHelper.fetchJSON({
url: `/groups/${encodeURIComponent(group)}/detail`,
errFn: opt_errFn,
anonymizedUrl: '/groups/*/detail',
@@ -568,7 +328,7 @@
// supports it.
const encodeName = encodeURIComponent(repo);
const encodeRef = encodeURIComponent(ref);
- return this._send({
+ return this._restApiHelper.send({
method: 'DELETE',
url: `/projects/${encodeName}/branches/${encodeRef}`,
body: '',
@@ -588,7 +348,7 @@
// supports it.
const encodeName = encodeURIComponent(repo);
const encodeRef = encodeURIComponent(ref);
- return this._send({
+ return this._restApiHelper.send({
method: 'DELETE',
url: `/projects/${encodeName}/tags/${encodeRef}`,
body: '',
@@ -609,7 +369,7 @@
// supports it.
const encodeName = encodeURIComponent(name);
const encodeBranch = encodeURIComponent(branch);
- return this._send({
+ return this._restApiHelper.send({
method: 'PUT',
url: `/projects/${encodeName}/branches/${encodeBranch}`,
body: revision,
@@ -630,7 +390,7 @@
// supports it.
const encodeName = encodeURIComponent(name);
const encodeTag = encodeURIComponent(tag);
- return this._send({
+ return this._restApiHelper.send({
method: 'PUT',
url: `/projects/${encodeName}/tags/${encodeTag}`,
body: revision,
@@ -655,7 +415,7 @@
getGroupMembers(groupName, opt_errFn) {
const encodeName = encodeURIComponent(groupName);
- return this._fetchJSON({
+ return this._restApiHelper.fetchJSON({
url: `/groups/${encodeName}/members/`,
errFn: opt_errFn,
anonymizedUrl: '/groups/*/members',
@@ -663,7 +423,7 @@
},
getIncludedGroup(groupName) {
- return this._fetchJSON({
+ return this._restApiHelper.fetchJSON({
url: `/groups/${encodeURIComponent(groupName)}/groups/`,
anonymizedUrl: '/groups/*/groups',
});
@@ -671,7 +431,7 @@
saveGroupName(groupId, name) {
const encodeId = encodeURIComponent(groupId);
- return this._send({
+ return this._restApiHelper.send({
method: 'PUT',
url: `/groups/${encodeId}/name`,
body: {name},
@@ -681,7 +441,7 @@
saveGroupOwner(groupId, ownerId) {
const encodeId = encodeURIComponent(groupId);
- return this._send({
+ return this._restApiHelper.send({
method: 'PUT',
url: `/groups/${encodeId}/owner`,
body: {owner: ownerId},
@@ -691,7 +451,7 @@
saveGroupDescription(groupId, description) {
const encodeId = encodeURIComponent(groupId);
- return this._send({
+ return this._restApiHelper.send({
method: 'PUT',
url: `/groups/${encodeId}/description`,
body: {description},
@@ -701,7 +461,7 @@
saveGroupOptions(groupId, options) {
const encodeId = encodeURIComponent(groupId);
- return this._send({
+ return this._restApiHelper.send({
method: 'PUT',
url: `/groups/${encodeId}/options`,
body: options,
@@ -720,7 +480,7 @@
saveGroupMembers(groupName, groupMembers) {
const encodeName = encodeURIComponent(groupName);
const encodeMember = encodeURIComponent(groupMembers);
- return this._send({
+ return this._restApiHelper.send({
method: 'PUT',
url: `/groups/${encodeName}/members/${encodeMember}`,
parseResponse: true,
@@ -737,7 +497,7 @@
errFn: opt_errFn,
anonymizedUrl: '/groups/*/groups/*',
};
- return this._send(req).then(response => {
+ return this._restApiHelper.send(req).then(response => {
if (response.ok) {
return this.getResponseObject(response);
}
@@ -747,7 +507,7 @@
deleteGroupMembers(groupName, groupMembers) {
const encodeName = encodeURIComponent(groupName);
const encodeMember = encodeURIComponent(groupMembers);
- return this._send({
+ return this._restApiHelper.send({
method: 'DELETE',
url: `/groups/${encodeName}/members/${encodeMember}`,
anonymizedUrl: '/groups/*/members/*',
@@ -757,7 +517,7 @@
deleteIncludedGroup(groupName, includedGroup) {
const encodeName = encodeURIComponent(groupName);
const encodeIncludedGroup = encodeURIComponent(includedGroup);
- return this._send({
+ return this._restApiHelper.send({
method: 'DELETE',
url: `/groups/${encodeName}/groups/${encodeIncludedGroup}`,
anonymizedUrl: '/groups/*/groups/*',
@@ -844,7 +604,7 @@
prefs.download_scheme = prefs.download_scheme.toLowerCase();
}
- return this._send({
+ return this._restApiHelper.send({
method: 'PUT',
url: '/accounts/self/preferences',
body: prefs,
@@ -860,7 +620,7 @@
saveDiffPreferences(prefs, opt_errFn) {
// Invalidate the cache.
this._cache.delete('/accounts/self/preferences.diff');
- return this._send({
+ return this._restApiHelper.send({
method: 'PUT',
url: '/accounts/self/preferences.diff',
body: prefs,
@@ -876,7 +636,7 @@
saveEditPreferences(prefs, opt_errFn) {
// Invalidate the cache.
this._cache.delete('/accounts/self/preferences.edit');
- return this._send({
+ return this._restApiHelper.send({
method: 'PUT',
url: '/accounts/self/preferences.edit',
body: prefs,
@@ -910,14 +670,14 @@
},
getExternalIds() {
- return this._fetchJSON({
+ return this._restApiHelper.fetchJSON({
url: '/accounts/self/external.ids',
reportUrlAsIs: true,
});
},
deleteAccountIdentity(id) {
- return this._send({
+ return this._restApiHelper.send({
method: 'POST',
url: '/accounts/self/external.ids:delete',
body: id,
@@ -931,7 +691,7 @@
* @return {!Promise<!Object>}
*/
getAccountDetails(userId) {
- return this._fetchJSON({
+ return this._restApiHelper.fetchJSON({
url: `/accounts/${encodeURIComponent(userId)}/detail`,
anonymizedUrl: '/accounts/*/detail',
});
@@ -949,7 +709,7 @@
* @param {function(?Response, string=)=} opt_errFn
*/
addAccountEmail(email, opt_errFn) {
- return this._send({
+ return this._restApiHelper.send({
method: 'PUT',
url: '/accounts/self/emails/' + encodeURIComponent(email),
errFn: opt_errFn,
@@ -962,7 +722,7 @@
* @param {function(?Response, string=)=} opt_errFn
*/
deleteAccountEmail(email, opt_errFn) {
- return this._send({
+ return this._restApiHelper.send({
method: 'DELETE',
url: '/accounts/self/emails/' + encodeURIComponent(email),
errFn: opt_errFn,
@@ -982,7 +742,7 @@
errFn: opt_errFn,
anonymizedUrl: '/accounts/self/emails/*/preferred',
};
- return this._send(req).then(() => {
+ return this._restApiHelper.send(req).then(() => {
// If result of getAccountEmails is in cache, update it in the cache
// so we don't have to invalidate it.
const cachedEmails = this._cache.get('/accounts/self/emails');
@@ -1026,7 +786,7 @@
parseResponse: true,
reportUrlAsIs: true,
};
- return this._send(req)
+ return this._restApiHelper.send(req)
.then(newName => this._updateCachedAccount({name: newName}));
},
@@ -1043,7 +803,7 @@
parseResponse: true,
reportUrlAsIs: true,
};
- return this._send(req)
+ return this._restApiHelper.send(req)
.then(newName => this._updateCachedAccount({username: newName}));
},
@@ -1060,33 +820,33 @@
parseResponse: true,
reportUrlAsIs: true,
};
- return this._send(req)
+ return this._restApiHelper.send(req)
.then(newStatus => this._updateCachedAccount({status: newStatus}));
},
getAccountStatus(userId) {
- return this._fetchJSON({
+ return this._restApiHelper.fetchJSON({
url: `/accounts/${encodeURIComponent(userId)}/status`,
anonymizedUrl: '/accounts/*/status',
});
},
getAccountGroups() {
- return this._fetchJSON({
+ return this._restApiHelper.fetchJSON({
url: '/accounts/self/groups',
reportUrlAsIs: true,
});
},
getAccountAgreements() {
- return this._fetchJSON({
+ return this._restApiHelper.fetchJSON({
url: '/accounts/self/agreements',
reportUrlAsIs: true,
});
},
saveAccountAgreement(name) {
- return this._send({
+ return this._restApiHelper.send({
method: 'PUT',
url: '/accounts/self/agreements',
body: name,
@@ -1131,34 +891,7 @@
},
checkCredentials() {
- if (this._credentialCheck.checking) {
- return;
- }
- this._credentialCheck.checking = true;
- let req = {url: '/accounts/self/detail', reportUrlAsIs: true};
- req = this._addAcceptJsonHeader(req);
- // Skip the REST response cache.
- return this._fetchRawJSON(req).then(res => {
- if (!res) { return; }
- if (res.status === 403) {
- this.fire('auth-error');
- this._cache.delete('/accounts/self/detail');
- } else if (res.ok) {
- return this.getResponseObject(res);
- }
- }).then(res => {
- this._credentialCheck.checking = false;
- if (res) {
- this._cache.set('/accounts/self/detail', res);
- }
- return res;
- }).catch(err => {
- this._credentialCheck.checking = false;
- if (err && err.message === FAILED_TO_FETCH_ERROR) {
- this.fire('auth-error');
- this._cache.delete('/accounts/self/detail');
- }
- });
+ return this._restApiHelper.checkCredentials();
},
getDefaultPreferences() {
@@ -1204,7 +937,7 @@
* @param {function(?Response, string=)=} opt_errFn
*/
saveWatchedProjects(projects, opt_errFn) {
- return this._send({
+ return this._restApiHelper.send({
method: 'POST',
url: '/accounts/self/watched.projects',
body: projects,
@@ -1219,7 +952,7 @@
* @param {function(?Response, string=)=} opt_errFn
*/
deleteWatchedProjects(projects, opt_errFn) {
- return this._send({
+ return this._restApiHelper.send({
method: 'POST',
url: '/accounts/self/watched.projects:delete',
body: projects,
@@ -1228,45 +961,6 @@
});
},
- /**
- * @param {Defs.FetchJSONRequest} req
- */
- _fetchSharedCacheURL(req) {
- if (this._sharedFetchPromises[req.url]) {
- return this._sharedFetchPromises[req.url];
- }
- // TODO(andybons): Periodic cache invalidation.
- if (this._cache.has(req.url)) {
- return Promise.resolve(this._cache.get(req.url));
- }
- this._sharedFetchPromises[req.url] = this._fetchJSON(req)
- .then(response => {
- if (response !== undefined) {
- this._cache.set(req.url, response);
- }
- this._sharedFetchPromises[req.url] = undefined;
- return response;
- }).catch(err => {
- this._sharedFetchPromises[req.url] = undefined;
- throw err;
- });
- return this._sharedFetchPromises[req.url];
- },
-
- /**
- * @param {string} prefix
- */
- _invalidateSharedFetchPromisesPrefix(prefix) {
- const newObject = {};
- Object.entries(this._sharedFetchPromises).forEach(([key, value]) => {
- if (!key.startsWith(prefix)) {
- newObject[key] = value;
- }
- });
- this._sharedFetchPromises = newObject;
- this._cache.invalidatePrefix(prefix);
- },
-
_isNarrowScreen() {
return window.innerWidth < MAX_UNIFIED_DEFAULT_WINDOW_WIDTH_PX;
},
@@ -1308,7 +1002,7 @@
params,
reportUrlAsIs: true,
};
- return this._fetchJSON(req).then(response => {
+ return this._restApiHelper.fetchJSON(req).then(response => {
// Response may be an array of changes OR an array of arrays of
// changes.
if (opt_query instanceof Array) {
@@ -1407,7 +1101,8 @@
*/
_getChangeDetail(changeNum, optionsHex, opt_errFn, opt_cancelCondition) {
return this.getChangeActionURL(changeNum, null, '/detail').then(url => {
- const urlWithParams = this._urlWithParams(url, optionsHex);
+ const urlWithParams = this._restApiHelper
+ .urlWithParams(url, optionsHex);
const params = {O: optionsHex};
let req = {
url,
@@ -1417,10 +1112,10 @@
fetchOptions: this._etags.getOptions(urlWithParams),
anonymizedUrl: '/changes/*~*/detail?O=' + optionsHex,
};
- req = this._addAcceptJsonHeader(req);
- return this._fetchRawJSON(req).then(response => {
+ req = this._restApiHelper.addAcceptJsonHeader(req);
+ return this._restApiHelper.fetchRawJSON(req).then(response => {
if (response && response.status === 304) {
- return Promise.resolve(this._parsePrefixedJSON(
+ return Promise.resolve(this._restApiHelper.parsePrefixedJSON(
this._etags.getCachedPayload(urlWithParams)));
}
@@ -1434,7 +1129,7 @@
}
const payloadPromise = response ?
- this._readResponsePayload(response) :
+ this._restApiHelper.readResponsePayload(response) :
Promise.resolve(null);
return payloadPromise.then(payload => {
@@ -1647,11 +1342,11 @@
},
invalidateGroupsCache() {
- this._invalidateSharedFetchPromisesPrefix('/groups/?');
+ this._restApiHelper.invalidateFetchPromisesPrefix('/groups/?');
},
invalidateReposCache() {
- this._invalidateSharedFetchPromisesPrefix('/projects/?');
+ this._restApiHelper.invalidateFetchPromisesPrefix('/projects/?');
},
/**
@@ -1689,7 +1384,7 @@
setRepoHead(repo, ref) {
// TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
// supports it.
- return this._send({
+ return this._restApiHelper.send({
method: 'PUT',
url: `/projects/${encodeURIComponent(repo)}/HEAD`,
body: {ref},
@@ -1713,7 +1408,7 @@
const url = `/projects/${repo}/branches?n=${count}&S=${offset}${filter}`;
// TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
// supports it.
- return this._fetchJSON({
+ return this._restApiHelper.fetchJSON({
url,
errFn: opt_errFn,
anonymizedUrl: '/projects/*/branches?*',
@@ -1737,7 +1432,7 @@
encodedFilter;
// TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
// supports it.
- return this._fetchJSON({
+ return this._restApiHelper.fetchJSON({
url,
errFn: opt_errFn,
anonymizedUrl: '/projects/*/tags',
@@ -1756,7 +1451,7 @@
const encodedFilter = this._computeFilter(filter);
const n = pluginsPerPage + 1;
const url = `/plugins/?all&n=${n}&S=${offset}${encodedFilter}`;
- return this._fetchJSON({
+ return this._restApiHelper.fetchJSON({
url,
errFn: opt_errFn,
anonymizedUrl: '/plugins/?all',
@@ -1766,7 +1461,7 @@
getRepoAccessRights(repoName, opt_errFn) {
// TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
// supports it.
- return this._fetchJSON({
+ return this._restApiHelper.fetchJSON({
url: `/projects/${encodeURIComponent(repoName)}/access`,
errFn: opt_errFn,
anonymizedUrl: '/projects/*/access',
@@ -1776,7 +1471,7 @@
setRepoAccessRights(repoName, repoInfo) {
// TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
// supports it.
- return this._send({
+ return this._restApiHelper.send({
method: 'POST',
url: `/projects/${encodeURIComponent(repoName)}/access`,
body: repoInfo,
@@ -1785,7 +1480,7 @@
},
setRepoAccessRightsForReview(projectName, projectInfo) {
- return this._send({
+ return this._restApiHelper.send({
method: 'PUT',
url: `/projects/${encodeURIComponent(projectName)}/access:review`,
body: projectInfo,
@@ -1802,7 +1497,7 @@
getSuggestedGroups(inputVal, opt_n, opt_errFn) {
const params = {s: inputVal};
if (opt_n) { params.n = opt_n; }
- return this._fetchJSON({
+ return this._restApiHelper.fetchJSON({
url: '/groups/',
errFn: opt_errFn,
params,
@@ -1822,7 +1517,7 @@
type: 'ALL',
};
if (opt_n) { params.n = opt_n; }
- return this._fetchJSON({
+ return this._restApiHelper.fetchJSON({
url: '/projects/',
errFn: opt_errFn,
params,
@@ -1841,7 +1536,7 @@
}
const params = {suggest: null, q: inputVal};
if (opt_n) { params.n = opt_n; }
- return this._fetchJSON({
+ return this._restApiHelper.fetchJSON({
url: '/accounts/',
errFn: opt_errFn,
params,
@@ -1872,7 +1567,7 @@
throw Error('Unsupported HTTP method: ' + method);
}
- return this._send({method, url, body});
+ return this._restApiHelper.send({method, url, body});
});
},
@@ -1902,7 +1597,7 @@
O: options,
q: 'status:open is:mergeable conflicts:' + changeNum,
};
- return this._fetchJSON({
+ return this._restApiHelper.fetchJSON({
url: '/changes/',
params,
anonymizedUrl: '/changes/conflicts:*',
@@ -1924,7 +1619,7 @@
O: options,
q: query,
};
- return this._fetchJSON({
+ return this._restApiHelper.fetchJSON({
url: '/changes/',
params,
anonymizedUrl: '/changes/change:*',
@@ -1947,7 +1642,7 @@
O: options,
q: query,
};
- return this._fetchJSON({
+ return this._restApiHelper.fetchJSON({
url: '/changes/',
params,
anonymizedUrl: '/changes/topic:*',
@@ -1993,7 +1688,7 @@
this.getChangeActionURL(changeNum, patchNum, '/review'),
];
return Promise.all(promises).then(([, url]) => {
- return this._send({
+ return this._restApiHelper.send({
method: 'POST',
url,
body: review,
@@ -2027,7 +1722,7 @@
*/
createChange(project, branch, subject, opt_topic, opt_isPrivate,
opt_workInProgress, opt_baseChange, opt_baseCommit) {
- return this._send({
+ return this._restApiHelper.send({
method: 'POST',
url: '/changes/',
body: {
@@ -2202,7 +1897,7 @@
return this.getFromProjectLookup(changeNum).then(project => {
const url = '/accounts/self/starred.changes/' +
(project ? encodeURIComponent(project) + '~' : '') + changeNum;
- return this._send({
+ return this._restApiHelper.send({
method: starred ? 'PUT' : 'DELETE',
url,
anonymizedUrl: '/accounts/self/starred.changes/*',
@@ -2219,59 +1914,7 @@
},
/**
- * Send an XHR.
- * @param {Defs.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 : this.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.fire('server-error', {request: fetchReq, response});
- }
- return response;
- }).catch(err => {
- this.fire('network-error', {error: err});
- 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;
- },
-
- /**
- * Public version of the _send method preserved for plugins.
+ * Public version of the _restApiHelper.send method preserved for plugins.
* @param {string} method
* @param {string} url
* @param {?string|number|Object=} opt_body passed as null sometimes
@@ -2284,7 +1927,7 @@
*/
send(method, url, opt_body, opt_errFn, opt_contentType,
opt_headers) {
- return this._send({
+ return this._restApiHelper.send({
method,
url,
body: opt_body,
@@ -2541,7 +2184,7 @@
},
getCommitInfo(project, commit) {
- return this._fetchJSON({
+ return this._restApiHelper.fetchJSON({
url: '/projects/' + encodeURIComponent(project) +
'/commits/' + encodeURIComponent(commit),
anonymizedUrl: '/projects/*/comments/*',
@@ -2549,7 +2192,7 @@
},
_fetchB64File(url) {
- return this._fetch({url: this.getBaseUrl() + url})
+ return this._restApiHelper.fetch({url: this.getBaseUrl() + url})
.then(response => {
if (!response.ok) {
return Promise.reject(new Error(response.statusText));
@@ -2673,7 +2316,7 @@
},
deleteAccountHttpPassword() {
- return this._send({
+ return this._restApiHelper.send({
method: 'DELETE',
url: '/accounts/self/password.http',
reportUrlAsIs: true,
@@ -2686,7 +2329,7 @@
* parameter.
*/
generateAccountHttpPassword() {
- return this._send({
+ return this._restApiHelper.send({
method: 'PUT',
url: '/accounts/self/password.http',
body: {generate: true},
@@ -2710,7 +2353,7 @@
contentType: 'plain/text',
reportUrlAsIs: true,
};
- return this._send(req)
+ return this._restApiHelper.send(req)
.then(response => {
if (response.status < 200 && response.status >= 300) {
return Promise.reject(new Error('error'));
@@ -2724,7 +2367,7 @@
},
deleteAccountSSHKey(id) {
- return this._send({
+ return this._restApiHelper.send({
method: 'DELETE',
url: '/accounts/self/sshkeys/' + id,
anonymizedUrl: '/accounts/self/sshkeys/*',
@@ -2732,7 +2375,7 @@
},
getAccountGPGKeys() {
- return this._fetchJSON({
+ return this._restApiHelper.fetchJSON({
url: '/accounts/self/gpgkeys',
reportUrlAsIs: true,
});
@@ -2745,7 +2388,7 @@
body: key,
reportUrlAsIs: true,
};
- return this._send(req)
+ return this._restApiHelper.send(req)
.then(response => {
if (response.status < 200 && response.status >= 300) {
return Promise.reject(new Error('error'));
@@ -2759,7 +2402,7 @@
},
deleteAccountGPGKey(id) {
- return this._send({
+ return this._restApiHelper.send({
method: 'DELETE',
url: '/accounts/self/gpgkeys/' + id,
anonymizedUrl: '/accounts/self/gpgkeys/*',
@@ -2792,7 +2435,7 @@
body: {token},
reportUrlAsIs: true,
};
- return this._send(req).then(response => {
+ return this._restApiHelper.send(req).then(response => {
if (response.status === 204) {
return 'Email confirmed successfully.';
}
@@ -2801,7 +2444,7 @@
},
getCapabilities(opt_errFn) {
- return this._fetchJSON({
+ return this._restApiHelper.fetchJSON({
url: '/config/server/capabilities',
errFn: opt_errFn,
reportUrlAsIs: true,
@@ -2907,7 +2550,7 @@
*/
getChange(changeNum, opt_errFn) {
// Cannot use _changeBaseURL, as this function is used by _projectLookup.
- return this._fetchJSON({
+ return this._restApiHelper.fetchJSON({
url: `/changes/?q=change:${changeNum}`,
errFn: opt_errFn,
anonymizedUrl: '/changes/?q=change:*',
@@ -2967,7 +2610,7 @@
req.endpoint : req.anonymizedEndpoint;
return this._changeBaseURL(req.changeNum, req.patchNum).then(url => {
- return this._send({
+ return this._restApiHelper.send({
method: req.method,
url: url + req.endpoint,
body: req.body,
@@ -2992,7 +2635,7 @@
const anonymizedBaseUrl = req.patchNum ?
ANONYMIZED_REVISION_BASE_URL : ANONYMIZED_CHANGE_BASE_URL;
return this._changeBaseURL(req.changeNum, req.patchNum).then(url => {
- return this._fetchJSON({
+ return this._restApiHelper.fetchJSON({
url: url + req.endpoint,
errFn: req.errFn,
params: req.params,
@@ -3119,7 +2762,7 @@
},
deleteDraftComments(query) {
- return this._send({
+ return this._restApiHelper.send({
method: 'POST',
url: '/accounts/self/drafts:delete',
body: {query},
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 9d0d83a..ea71522 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
@@ -63,116 +63,8 @@
sandbox.restore();
});
- suite('fetchJSON()', () => {
- test('Sets header to accept application/json', () => {
- const authFetchStub = sandbox.stub(element._auth, 'fetch')
- .returns(Promise.resolve());
- element._fetchJSON({url: '/dummy/url'});
- assert.isTrue(authFetchStub.called);
- assert.equal(authFetchStub.lastCall.args[1].headers.get('Accept'),
- 'application/json');
- });
-
- test('Use header option accept when provided', () => {
- const authFetchStub = sandbox.stub(element._auth, 'fetch')
- .returns(Promise.resolve());
- const headers = new Headers();
- headers.append('Accept', '*/*');
- const fetchOptions = {headers};
- element._fetchJSON({url: '/dummy/url', fetchOptions});
- assert.isTrue(authFetchStub.called);
- assert.equal(authFetchStub.lastCall.args[1].headers.get('Accept'),
- '*/*');
- });
- });
-
- test('JSON prefix is properly removed', done => {
- element._fetchJSON({url: '/dummy/url'}).then(obj => {
- assert.deepEqual(obj, {hello: 'bonjour'});
- done();
- });
- });
-
- test('cached results', done => {
- let n = 0;
- sandbox.stub(element, '_fetchJSON', () => {
- return Promise.resolve(++n);
- });
- const promises = [];
- promises.push(element._fetchSharedCacheURL('/foo'));
- promises.push(element._fetchSharedCacheURL('/foo'));
- promises.push(element._fetchSharedCacheURL('/foo'));
-
- Promise.all(promises).then(results => {
- assert.deepEqual(results, [1, 1, 1]);
- element._fetchSharedCacheURL('/foo').then(foo => {
- assert.equal(foo, 1);
- done();
- });
- });
- });
-
- test('cached promise', done => {
- const promise = Promise.reject(new Error('foo'));
- element._cache.set('/foo', promise);
- element._fetchSharedCacheURL({url: '/foo'}).catch(p => {
- assert.equal(p.message, 'foo');
- done();
- });
- });
-
- test('cache invalidation', () => {
- element._cache.set('/foo/bar', 1);
- element._cache.set('/bar', 2);
- element._sharedFetchPromises['/foo/bar'] = 3;
- element._sharedFetchPromises['/bar'] = 4;
- element._invalidateSharedFetchPromisesPrefix('/foo/');
- assert.isFalse(element._cache.has('/foo/bar'));
- assert.isTrue(element._cache.has('/bar'));
- assert.isUndefined(element._sharedFetchPromises['/foo/bar']);
- assert.strictEqual(4, element._sharedFetchPromises['/bar']);
- });
-
- test('params are properly encoded', () => {
- let url = element._urlWithParams('/path/', {
- sp: 'hola',
- gr: 'guten tag',
- noval: null,
- });
- assert.equal(url,
- window.CANONICAL_PATH + '/path/?sp=hola&gr=guten%20tag&noval');
-
- url = element._urlWithParams('/path/', {
- sp: 'hola',
- en: ['hey', 'hi'],
- });
- assert.equal(url, window.CANONICAL_PATH + '/path/?sp=hola&en=hey&en=hi');
-
- // Order must be maintained with array params.
- url = element._urlWithParams('/path/', {
- l: ['c', 'b', 'a'],
- });
- assert.equal(url, window.CANONICAL_PATH + '/path/?l=c&l=b&l=a');
- });
-
- test('request callbacks can be canceled', done => {
- let cancelCalled = false;
- window.fetch.returns(Promise.resolve({
- body: {
- cancel() { cancelCalled = true; },
- },
- }));
- const cancelCondition = () => { return true; };
- element._fetchJSON({url: '/dummy/url', cancelCondition}).then(
- obj => {
- assert.isUndefined(obj);
- assert.isTrue(cancelCalled);
- done();
- });
- });
-
test('parent diff comments are properly grouped', done => {
- sandbox.stub(element, '_fetchJSON', () => {
+ sandbox.stub(element._restApiHelper, 'fetchJSON', () => {
return Promise.resolve({
'/COMMIT_MSG': [],
'sieve.go': [
@@ -315,7 +207,7 @@
test('differing patch diff comments are properly grouped', done => {
sandbox.stub(element, 'getFromProjectLookup')
.returns(Promise.resolve('test'));
- sandbox.stub(element, '_fetchJSON', request => {
+ sandbox.stub(element._restApiHelper, 'fetchJSON', request => {
const url = request.url;
if (url === '/changes/test~42/revisions/1') {
return Promise.resolve({
@@ -432,7 +324,7 @@
suite('rebase action', () => {
let resolve_fetchJSON;
setup(() => {
- sandbox.stub(element, '_fetchJSON').returns(
+ sandbox.stub(element._restApiHelper, 'fetchJSON').returns(
new Promise(resolve => {
resolve_fetchJSON = resolve;
}));
@@ -467,7 +359,7 @@
element.addEventListener('server-error', resolve);
});
- element._fetchJSON({}).then(response => {
+ element._restApiHelper.fetchJSON({}).then(response => {
assert.isUndefined(response);
assert.isTrue(getResponseObjectStub.notCalled);
serverErrorEventPromise.then(() => done());
@@ -483,12 +375,12 @@
Promise.reject(new Error('Failed to fetch')));
window.fetch.onSecondCall().returns(Promise.resolve(fakeAuthResponse));
// Emulate logged in.
- element._cache.set('/accounts/self/detail', {});
+ element._restApiHelper._cache.set('/accounts/self/detail', {});
const serverErrorStub = sandbox.stub();
element.addEventListener('server-error', serverErrorStub);
const authErrorStub = sandbox.stub();
element.addEventListener('auth-error', authErrorStub);
- element._fetchJSON({url: '/bar'}).finally(r => {
+ element._restApiHelper.fetchJSON({url: '/bar'}).finally(r => {
flush(() => {
assert.isTrue(authErrorStub.called);
assert.isFalse(serverErrorStub.called);
@@ -507,7 +399,7 @@
element.addEventListener('server-error', serverErrorStub);
const authErrorStub = sandbox.stub();
element.addEventListener('auth-error', authErrorStub);
- element._fetchJSON({url: '/bar'}).finally(r => {
+ element._restApiHelper.fetchJSON({url: '/bar'}).finally(r => {
flush(() => {
assert.isTrue(authErrorStub.called);
assert.isFalse(serverErrorStub.called);
@@ -558,7 +450,8 @@
test('checkCredentials promise rejection', () => {
window.fetch.restore();
element._cache.set('/accounts/self/detail', true);
- sandbox.spy(element, 'checkCredentials');
+ const checkCredentialsSpy =
+ sandbox.spy(element._restApiHelper, 'checkCredentials');
sandbox.stub(window, 'fetch', url => {
return Promise.reject(new Error('Failed to fetch'));
});
@@ -570,7 +463,7 @@
// The second fetch call also fails, which leads to a second
// invocation of checkCredentials, which should immediately
// return instead of making further fetch calls.
- assert.isTrue(element.checkCredentials.calledTwice);
+ assert.isTrue(checkCredentialsSpy .calledTwice);
assert.isTrue(window.fetch.calledTwice);
});
});
@@ -585,7 +478,7 @@
});
test('legacy n,z key in change url is replaced', () => {
- const stub = sandbox.stub(element, '_fetchJSON')
+ const stub = sandbox.stub(element._restApiHelper, 'fetchJSON')
.returns(Promise.resolve([]));
element.getChanges(1, null, 'n,z');
assert.equal(stub.lastCall.args[0].params.S, 0);
@@ -593,38 +486,38 @@
test('saveDiffPreferences invalidates cache line', () => {
const cacheKey = '/accounts/self/preferences.diff';
- sandbox.stub(element, '_send');
+ const sendStub = sandbox.stub(element._restApiHelper, 'send');
element._cache.set(cacheKey, {tab_size: 4});
element.saveDiffPreferences({tab_size: 8});
- assert.isTrue(element._send.called);
- assert.isFalse(element._cache.has(cacheKey));
+ assert.isTrue(sendStub.called);
+ assert.isFalse(element._restApiHelper._cache.has(cacheKey));
});
test('getAccount when resp is null does not add anything to the cache',
done => {
const cacheKey = '/accounts/self/detail';
- const stub = sandbox.stub(element, '_fetchSharedCacheURL',
+ const stub = sandbox.stub(element._restApiHelper, 'fetchCacheURL',
() => Promise.resolve());
element.getAccount().then(() => {
- assert.isTrue(element._fetchSharedCacheURL.called);
- assert.isFalse(element._cache.has(cacheKey));
+ assert.isTrue(stub.called);
+ assert.isFalse(element._restApiHelper._cache.has(cacheKey));
done();
});
- element._cache.set(cacheKey, 'fake cache');
+ element._restApiHelper._cache.set(cacheKey, 'fake cache');
stub.lastCall.args[0].errFn();
});
test('getAccount does not add to the cache when resp.status is 403',
done => {
const cacheKey = '/accounts/self/detail';
- const stub = sandbox.stub(element, '_fetchSharedCacheURL',
+ const stub = sandbox.stub(element._restApiHelper, 'fetchCacheURL',
() => Promise.resolve());
element.getAccount().then(() => {
- assert.isTrue(element._fetchSharedCacheURL.called);
- assert.isFalse(element._cache.has(cacheKey));
+ assert.isTrue(stub.called);
+ assert.isFalse(element._restApiHelper._cache.has(cacheKey));
done();
});
element._cache.set(cacheKey, 'fake cache');
@@ -633,15 +526,15 @@
test('getAccount when resp is successful', done => {
const cacheKey = '/accounts/self/detail';
- const stub = sandbox.stub(element, '_fetchSharedCacheURL',
+ const stub = sandbox.stub(element._restApiHelper, 'fetchCacheURL',
() => Promise.resolve());
element.getAccount().then(response => {
- assert.isTrue(element._fetchSharedCacheURL.called);
- assert.equal(element._cache.get(cacheKey), 'fake cache');
+ assert.isTrue(stub.called);
+ assert.equal(element._restApiHelper._cache.get(cacheKey), 'fake cache');
done();
});
- element._cache.set(cacheKey, 'fake cache');
+ element._restApiHelper._cache.set(cacheKey, 'fake cache');
stub.lastCall.args[0].errFn({});
});
@@ -653,7 +546,7 @@
sandbox.stub(element, '_isNarrowScreen', () => {
return smallScreen;
});
- sandbox.stub(element, '_fetchSharedCacheURL', () => {
+ sandbox.stub(element._restApiHelper, 'fetchCacheURL', () => {
return Promise.resolve(testJSON);
});
};
@@ -718,10 +611,10 @@
});
test('savPreferences normalizes download scheme', () => {
- sandbox.stub(element, '_send');
+ const sendStub = sandbox.stub(element._restApiHelper, 'send');
element.savePreferences({download_scheme: 'HTTP'});
- assert.isTrue(element._send.called);
- assert.equal(element._send.lastCall.args[0].body.download_scheme, 'http');
+ assert.isTrue(sendStub.called);
+ assert.equal(sendStub.lastCall.args[0].body.download_scheme, 'http');
});
test('getDiffPreferences returns correct defaults', done => {
@@ -747,10 +640,10 @@
});
test('saveDiffPreferences set show_tabs to false', () => {
- sandbox.stub(element, '_send');
+ const sendStub = sandbox.stub(element._restApiHelper, 'send');
element.saveDiffPreferences({show_tabs: false});
- assert.isTrue(element._send.called);
- assert.equal(element._send.lastCall.args[0].body.show_tabs, false);
+ assert.isTrue(sendStub.called);
+ assert.equal(sendStub.lastCall.args[0].body.show_tabs, false);
});
test('getEditPreferences returns correct defaults', done => {
@@ -780,34 +673,36 @@
});
test('saveEditPreferences set show_tabs to false', () => {
- sandbox.stub(element, '_send');
+ const sendStub = sandbox.stub(element._restApiHelper, 'send');
element.saveEditPreferences({show_tabs: false});
- assert.isTrue(element._send.called);
- assert.equal(element._send.lastCall.args[0].body.show_tabs, false);
+ assert.isTrue(sendStub.called);
+ assert.equal(sendStub.lastCall.args[0].body.show_tabs, false);
});
test('confirmEmail', () => {
- sandbox.spy(element, '_send');
+ const sendStub = sandbox.spy(element._restApiHelper, 'send');
element.confirmEmail('foo');
- assert.isTrue(element._send.calledOnce);
- assert.equal(element._send.lastCall.args[0].method, 'PUT');
- assert.equal(element._send.lastCall.args[0].url,
+ assert.isTrue(sendStub.calledOnce);
+ assert.equal(sendStub.lastCall.args[0].method, 'PUT');
+ assert.equal(sendStub.lastCall.args[0].url,
'/config/server/email.confirm');
- assert.deepEqual(element._send.lastCall.args[0].body, {token: 'foo'});
+ assert.deepEqual(sendStub.lastCall.args[0].body, {token: 'foo'});
});
test('setAccountStatus', () => {
- sandbox.stub(element, '_send').returns(Promise.resolve('OOO'));
+ const sendStub = sandbox.stub(element._restApiHelper, 'send')
+ .returns(Promise.resolve('OOO'));
element._cache.set('/accounts/self/detail', {});
return element.setAccountStatus('OOO').then(() => {
- assert.isTrue(element._send.calledOnce);
- assert.equal(element._send.lastCall.args[0].method, 'PUT');
- assert.equal(element._send.lastCall.args[0].url,
+ assert.isTrue(sendStub.calledOnce);
+ assert.equal(sendStub.lastCall.args[0].method, 'PUT');
+ assert.equal(sendStub.lastCall.args[0].url,
'/accounts/self/status');
- assert.deepEqual(element._send.lastCall.args[0].body,
+ assert.deepEqual(sendStub.lastCall.args[0].body,
{status: 'OOO'});
- assert.deepEqual(element._cache.get('/accounts/self/detail'),
- {status: 'OOO'});
+ assert.deepEqual(element._restApiHelper
+ ._cache.get('/accounts/self/detail'),
+ {status: 'OOO'});
});
});
@@ -896,18 +791,20 @@
const change_num = '1';
const file_name = 'index.php';
const file_contents = '<?php';
- sandbox.stub(element, '_send').returns(
+ sandbox.stub(element._restApiHelper, 'send').returns(
Promise.resolve([change_num, file_name, file_contents]));
sandbox.stub(element, 'getResponseObject')
.returns(Promise.resolve([change_num, file_name, file_contents]));
element._cache.set('/changes/' + change_num + '/edit/' + file_name, {});
return element.saveChangeEdit(change_num, file_name, file_contents)
.then(() => {
- assert.isTrue(element._send.calledOnce);
- assert.equal(element._send.lastCall.args[0].method, 'PUT');
- assert.equal(element._send.lastCall.args[0].url,
+ assert.isTrue(element._restApiHelper.send.calledOnce);
+ assert.equal(element._restApiHelper.send.lastCall.args[0].method,
+ 'PUT');
+ assert.equal(element._restApiHelper.send.lastCall.args[0].url,
'/changes/test~1/edit/' + file_name);
- assert.equal(element._send.lastCall.args[0].body, file_contents);
+ assert.equal(element._restApiHelper.send.lastCall.args[0].body,
+ file_contents);
});
});
@@ -915,17 +812,18 @@
element._projectLookup = {1: 'test'};
const change_num = '1';
const message = 'this is a commit message';
- sandbox.stub(element, '_send').returns(
+ sandbox.stub(element._restApiHelper, 'send').returns(
Promise.resolve([change_num, message]));
sandbox.stub(element, 'getResponseObject')
.returns(Promise.resolve([change_num, message]));
element._cache.set('/changes/' + change_num + '/message', {});
return element.putChangeCommitMessage(change_num, message).then(() => {
- assert.isTrue(element._send.calledOnce);
- assert.equal(element._send.lastCall.args[0].method, 'PUT');
- assert.equal(element._send.lastCall.args[0].url,
+ assert.isTrue(element._restApiHelper.send.calledOnce);
+ assert.equal(element._restApiHelper.send.lastCall.args[0].method, 'PUT');
+ assert.equal(element._restApiHelper.send.lastCall.args[0].url,
'/changes/test~1/message');
- assert.deepEqual(element._send.lastCall.args[0].body, {message});
+ assert.deepEqual(element._restApiHelper.send.lastCall.args[0].body,
+ {message});
});
});
@@ -981,7 +879,7 @@
});
test('createRepo encodes name', () => {
- const sendStub = sandbox.stub(element, '_send')
+ const sendStub = sandbox.stub(element._restApiHelper, 'send')
.returns(Promise.resolve());
return element.createRepo({name: 'x/y'}).then(() => {
assert.isTrue(sendStub.calledOnce);
@@ -1027,64 +925,65 @@
suite('getRepos', () => {
const defaultQuery = 'state%3Aactive%20OR%20state%3Aread-only';
-
+ let fetchCacheURLStub;
setup(() => {
- sandbox.stub(element, '_fetchSharedCacheURL');
+ fetchCacheURLStub =
+ sandbox.stub(element._restApiHelper, 'fetchCacheURL');
});
test('normal use', () => {
element.getRepos('test', 25);
- assert.equal(element._fetchSharedCacheURL.lastCall.args[0].url,
+ assert.equal(fetchCacheURLStub.lastCall.args[0].url,
'/projects/?n=26&S=0&query=test');
element.getRepos(null, 25);
- assert.equal(element._fetchSharedCacheURL.lastCall.args[0].url,
+ assert.equal(fetchCacheURLStub.lastCall.args[0].url,
`/projects/?n=26&S=0&query=${defaultQuery}`);
element.getRepos('test', 25, 25);
- assert.equal(element._fetchSharedCacheURL.lastCall.args[0].url,
+ assert.equal(fetchCacheURLStub.lastCall.args[0].url,
'/projects/?n=26&S=25&query=test');
});
test('with blank', () => {
element.getRepos('test/test', 25);
- assert.equal(element._fetchSharedCacheURL.lastCall.args[0].url,
+ assert.equal(fetchCacheURLStub.lastCall.args[0].url,
'/projects/?n=26&S=0&query=inname%3Atest%20AND%20inname%3Atest');
});
test('with hyphen', () => {
element.getRepos('foo-bar', 25);
- assert.equal(element._fetchSharedCacheURL.lastCall.args[0].url,
+ assert.equal(fetchCacheURLStub.lastCall.args[0].url,
'/projects/?n=26&S=0&query=inname%3Afoo%20AND%20inname%3Abar');
});
test('with leading hyphen', () => {
element.getRepos('-bar', 25);
- assert.equal(element._fetchSharedCacheURL.lastCall.args[0].url,
+ assert.equal(fetchCacheURLStub.lastCall.args[0].url,
'/projects/?n=26&S=0&query=inname%3Abar');
});
test('with trailing hyphen', () => {
element.getRepos('foo-bar-', 25);
- assert.equal(element._fetchSharedCacheURL.lastCall.args[0].url,
+ assert.equal(fetchCacheURLStub.lastCall.args[0].url,
'/projects/?n=26&S=0&query=inname%3Afoo%20AND%20inname%3Abar');
});
test('with underscore', () => {
element.getRepos('foo_bar', 25);
- assert.equal(element._fetchSharedCacheURL.lastCall.args[0].url,
+ assert.equal(fetchCacheURLStub.lastCall.args[0].url,
'/projects/?n=26&S=0&query=inname%3Afoo%20AND%20inname%3Abar');
});
test('with underscore', () => {
element.getRepos('foo_bar', 25);
- assert.equal(element._fetchSharedCacheURL.lastCall.args[0].url,
+ assert.equal(fetchCacheURLStub.lastCall.args[0].url,
'/projects/?n=26&S=0&query=inname%3Afoo%20AND%20inname%3Abar');
});
test('hyphen only', () => {
element.getRepos('-', 25);
- assert.equal(element._fetchSharedCacheURL.lastCall.args[0].url,
+ assert.equal(fetchCacheURLStub.lastCall.args[0].url,
`/projects/?n=26&S=0&query=${defaultQuery}`);
});
});
@@ -1113,43 +1012,45 @@
});
suite('getGroups', () => {
+ let fetchCacheURLStub;
setup(() => {
- sandbox.stub(element, '_fetchSharedCacheURL');
+ fetchCacheURLStub =
+ sandbox.stub(element._restApiHelper, 'fetchCacheURL');
});
test('normal use', () => {
element.getGroups('test', 25);
- assert.equal(element._fetchSharedCacheURL.lastCall.args[0].url,
+ assert.equal(fetchCacheURLStub.lastCall.args[0].url,
'/groups/?n=26&S=0&m=test');
element.getGroups(null, 25);
- assert.equal(element._fetchSharedCacheURL.lastCall.args[0].url,
+ assert.equal(fetchCacheURLStub.lastCall.args[0].url,
'/groups/?n=26&S=0');
element.getGroups('test', 25, 25);
- assert.equal(element._fetchSharedCacheURL.lastCall.args[0].url,
+ assert.equal(fetchCacheURLStub.lastCall.args[0].url,
'/groups/?n=26&S=25&m=test');
});
test('regex', () => {
element.getGroups('^test.*', 25);
- assert.equal(element._fetchSharedCacheURL.lastCall.args[0].url,
+ assert.equal(fetchCacheURLStub.lastCall.args[0].url,
'/groups/?n=26&S=0&r=%5Etest.*');
element.getGroups('^test.*', 25, 25);
- assert.equal(element._fetchSharedCacheURL.lastCall.args[0].url,
+ assert.equal(fetchCacheURLStub.lastCall.args[0].url,
'/groups/?n=26&S=25&r=%5Etest.*');
});
});
test('gerrit auth is used', () => {
sandbox.stub(Gerrit.Auth, 'fetch').returns(Promise.resolve());
- element._fetchJSON({url: 'foo'});
+ element._restApiHelper.fetchJSON({url: 'foo'});
assert(Gerrit.Auth.fetch.called);
});
test('getSuggestedAccounts does not return _fetchJSON', () => {
- const _fetchJSONSpy = sandbox.spy(element, '_fetchJSON');
+ const _fetchJSONSpy = sandbox.spy(element._restApiHelper, 'fetchJSON');
return element.getSuggestedAccounts().then(accts => {
assert.isFalse(_fetchJSONSpy.called);
assert.equal(accts.length, 0);
@@ -1157,7 +1058,7 @@
});
test('_fetchJSON gets called by getSuggestedAccounts', () => {
- const _fetchJSONStub = sandbox.stub(element, '_fetchJSON',
+ const _fetchJSONStub = sandbox.stub(element._restApiHelper, 'fetchJSON',
() => Promise.resolve());
return element.getSuggestedAccounts('own').then(() => {
assert.deepEqual(_fetchJSONStub.lastCall.args[0].params, {
@@ -1229,7 +1130,7 @@
const errFn = sinon.stub();
sandbox.stub(element, 'getChangeActionURL')
.returns(Promise.resolve(''));
- sandbox.stub(element, '_fetchRawJSON')
+ sandbox.stub(element._restApiHelper, 'fetchRawJSON')
.returns(Promise.resolve({ok: false, status: 500}));
return element._getChangeDetail(123, '516714', errFn).then(() => {
assert.isTrue(errFn.called);
@@ -1249,14 +1150,15 @@
test('_getChangeDetail populates _projectLookup', () => {
sandbox.stub(element, 'getChangeActionURL')
.returns(Promise.resolve(''));
- sandbox.stub(element, '_fetchRawJSON')
+ sandbox.stub(element._restApiHelper, 'fetchRawJSON')
.returns(Promise.resolve({ok: true}));
const mockResponse = {_number: 1, project: 'test'};
- sandbox.stub(element, '_readResponsePayload').returns(Promise.resolve({
- parsed: mockResponse,
- raw: JSON.stringify(mockResponse),
- }));
+ sandbox.stub(element._restApiHelper, 'readResponsePayload')
+ .returns(Promise.resolve({
+ parsed: mockResponse,
+ raw: JSON.stringify(mockResponse),
+ }));
return element._getChangeDetail(1, '516714').then(() => {
assert.equal(Object.keys(element._projectLookup).length, 1);
assert.equal(element._projectLookup[1], 'test');
@@ -1274,7 +1176,8 @@
const mockResponse = {foo: 'bar', baz: 42};
mockResponseSerial = element.JSON_PREFIX +
JSON.stringify(mockResponse);
- sandbox.stub(element, '_urlWithParams').returns(requestUrl);
+ sandbox.stub(element._restApiHelper, 'urlWithParams')
+ .returns(requestUrl);
sandbox.stub(element, 'getChangeActionURL')
.returns(Promise.resolve(requestUrl));
collectSpy = sandbox.spy(element._etags, 'collect');
@@ -1282,11 +1185,12 @@
});
test('contributes to cache', () => {
- sandbox.stub(element, '_fetchRawJSON').returns(Promise.resolve({
- text: () => Promise.resolve(mockResponseSerial),
- status: 200,
- ok: true,
- }));
+ sandbox.stub(element._restApiHelper, 'fetchRawJSON')
+ .returns(Promise.resolve({
+ text: () => Promise.resolve(mockResponseSerial),
+ status: 200,
+ ok: true,
+ }));
return element._getChangeDetail(123, '516714').then(detail => {
assert.isFalse(getPayloadSpy.called);
@@ -1297,11 +1201,12 @@
});
test('uses cache on HTTP 304', () => {
- sandbox.stub(element, '_fetchRawJSON').returns(Promise.resolve({
- text: () => Promise.resolve(mockResponseSerial),
- status: 304,
- ok: true,
- }));
+ sandbox.stub(element._restApiHelper, 'fetchRawJSON')
+ .returns(Promise.resolve({
+ text: () => Promise.resolve(mockResponseSerial),
+ status: 304,
+ ok: true,
+ }));
return element._getChangeDetail(123, {}).then(detail => {
assert.isFalse(collectSpy.called);
@@ -1346,7 +1251,7 @@
suite('getChanges populates _projectLookup', () => {
test('multiple queries', () => {
- sandbox.stub(element, '_fetchJSON')
+ sandbox.stub(element._restApiHelper, 'fetchJSON')
.returns(Promise.resolve([
[
{_number: 1, project: 'test'},
@@ -1366,7 +1271,7 @@
});
test('no query', () => {
- sandbox.stub(element, '_fetchJSON')
+ sandbox.stub(element._restApiHelper, 'fetchJSON')
.returns(Promise.resolve([
{_number: 1, project: 'test'},
{_number: 2, project: 'test'},
@@ -1386,7 +1291,7 @@
test('_getChangeURLAndFetch', () => {
element._projectLookup = {1: 'test'};
- const fetchStub = sandbox.stub(element, '_fetchJSON')
+ const fetchStub = sandbox.stub(element._restApiHelper, 'fetchJSON')
.returns(Promise.resolve());
const req = {changeNum: 1, endpoint: '/test', patchNum: 1};
return element._getChangeURLAndFetch(req).then(() => {
@@ -1397,7 +1302,7 @@
test('_getChangeURLAndSend', () => {
element._projectLookup = {1: 'test'};
- const sendStub = sandbox.stub(element, '_send')
+ const sendStub = sandbox.stub(element._restApiHelper, 'send')
.returns(Promise.resolve());
const req = {
@@ -1419,16 +1324,17 @@
const mockObject = {foo: 'bar', baz: 'foo'};
const serial = element.JSON_PREFIX + JSON.stringify(mockObject);
const mockResponse = {text: () => Promise.resolve(serial)};
- return element._readResponsePayload(mockResponse).then(payload => {
- assert.deepEqual(payload.parsed, mockObject);
- assert.equal(payload.raw, serial);
- });
+ return element._restApiHelper.readResponsePayload(mockResponse)
+ .then(payload => {
+ assert.deepEqual(payload.parsed, mockObject);
+ assert.equal(payload.raw, serial);
+ });
});
test('_parsePrefixedJSON', () => {
const obj = {x: 3, y: {z: 4}, w: 23};
const serial = element.JSON_PREFIX + JSON.stringify(obj);
- const result = element._parsePrefixedJSON(serial);
+ const result = element._restApiHelper.parsePrefixedJSON(serial);
assert.deepEqual(result, obj);
});
});
@@ -1450,7 +1356,7 @@
});
test('generateAccountHttpPassword', () => {
- const sendSpy = sandbox.spy(element, '_send');
+ const sendSpy = sandbox.spy(element._restApiHelper, 'send');
return element.generateAccountHttpPassword().then(() => {
assert.isTrue(sendSpy.calledOnce);
assert.deepEqual(sendSpy.lastCall.args[0].body, {generate: true});
@@ -1535,11 +1441,12 @@
});
test('getDashboard', () => {
- const fetchStub = sandbox.stub(element, '_fetchSharedCacheURL');
+ const fetchCacheURLStub = sandbox.stub(element._restApiHelper,
+ 'fetchCacheURL');
element.getDashboard('gerrit/project', 'default:main');
- assert.isTrue(fetchStub.calledOnce);
+ assert.isTrue(fetchCacheURLStub.calledOnce);
assert.equal(
- fetchStub.lastCall.args[0].url,
+ fetchCacheURLStub.lastCall.args[0].url,
'/projects/gerrit%2Fproject/dashboards/default%3Amain');
});
@@ -1607,7 +1514,7 @@
});
test('_fetch forwards request and logs', () => {
- const logStub = sandbox.stub(element, '_logCall');
+ const logStub = sandbox.stub(element._restApiHelper, '_logCall');
const response = {status: 404, text: sinon.stub()};
const url = 'my url';
const fetchOptions = {method: 'DELETE'};
@@ -1615,7 +1522,7 @@
const startTime = 123;
sandbox.stub(Date, 'now').returns(startTime);
const req = {url, fetchOptions};
- return element._fetch(req).then(() => {
+ return element._restApiHelper.fetch(req).then(() => {
assert.isTrue(logStub.calledOnce);
assert.isTrue(logStub.calledWith(req, startTime, response.status));
assert.isFalse(response.text.called);
@@ -1627,10 +1534,11 @@
const handler = sinon.stub();
element.addEventListener('rpc-log', handler);
- element._logCall({url: 'url'}, 100, 200);
+ element._restApiHelper._logCall({url: 'url'}, 100, 200);
assert.isFalse(handler.called);
- element._logCall({url: 'url', anonymizedUrl: 'not url'}, 100, 200);
+ element._restApiHelper
+ ._logCall({url: 'url', anonymizedUrl: 'not url'}, 100, 200);
flushAsynchronousOperations();
assert.isTrue(handler.calledOnce);
});
@@ -1639,7 +1547,7 @@
sandbox.stub(element, 'getFromProjectLookup')
.returns(Promise.resolve('test'));
const sendStub =
- sandbox.stub(element, '_send').returns(Promise.resolve());
+ sandbox.stub(element._restApiHelper, 'send').returns(Promise.resolve());
await element.saveChangeStarred(123, true);
assert.isTrue(sendStub.calledOnce);
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
new file mode 100644
index 0000000..d42abc3
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.js
@@ -0,0 +1,456 @@
+/**
+ * @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(window) {
+ 'use strict';
+
+ const Defs = {};
+
+ /**
+ * @typedef {{
+ * url: string,
+ * fetchOptions: (Object|null|undefined),
+ * anonymizedUrl: (string|undefined),
+ * }}
+ */
+ Defs.FetchRequest;
+
+ /**
+ * Object to describe a request for passing into fetchJSON or fetchRawJSON.
+ * - url is the URL for the request (excluding get params)
+ * - errFn is a function to invoke when the request fails.
+ * - cancelCondition is a function that, if provided and returns true, will
+ * cancel the response after it resolves.
+ * - params is a key-value hash to specify get params for the request URL.
+ * @typedef {{
+ * url: string,
+ * errFn: (function(?Response, string=)|null|undefined),
+ * cancelCondition: (function()|null|undefined),
+ * params: (Object|null|undefined),
+ * fetchOptions: (Object|null|undefined),
+ * anonymizedUrl: (string|undefined),
+ * reportUrlAsIs: (boolean|undefined),
+ * }}
+ */
+ Defs.FetchJSONRequest;
+
+ const JSON_PREFIX = ')]}\'';
+ const FAILED_TO_FETCH_ERROR = 'Failed to fetch';
+
+ /**
+ * Wrapper around Map for caching server responses. Site-based so that
+ * changes to CANONICAL_PATH will result in a different cache going into
+ * effect.
+ */
+ 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);
+ }
+ }
+
+ 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;
+ }
+ }
+
+ class GrRestApiHelper {
+ /**
+ * @param {SiteBasedCache} cache
+ * @param {object} auth
+ * @param {FetchPromisesCache} fetchPromisesCache
+ * @param {object} credentialCheck
+ * @param {object} restApiInterface
+ */
+ constructor(cache, auth, fetchPromisesCache, credentialCheck,
+ restApiInterface) {
+ this._cache = cache;// TODO: make it public
+ this._auth = auth;
+ this._fetchPromisesCache = fetchPromisesCache;
+ this._credentialCheck = credentialCheck;
+ this._restApiInterface = restApiInterface;
+ }
+
+ /**
+ * Wraps calls to the underlying authenticated fetch function (_auth.fetch)
+ * with timing and logging.
+ * @param {Defs.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 {Defs.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.log([
+ 'HTTP',
+ status,
+ method,
+ elapsed + 'ms',
+ req.anonymizedUrl || req.url,
+ `(${startAt.toISOString()}, ${endAt.toISOString()})`,
+ ].join(' '));
+ if (req.anonymizedUrl) {
+ this.fire('rpc-log',
+ {status, method, elapsed, anonymizedUrl: req.anonymizedUrl});
+ }
+ }
+
+ /**
+ * 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 {Defs.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 => {
+ const isLoggedIn = !!this._cache.get('/accounts/self/detail');
+ if (isLoggedIn && err && err.message === FAILED_TO_FETCH_ERROR) {
+ this.checkCredentials();
+ } else {
+ if (req.errFn) {
+ req.errFn.call(undefined, null, err);
+ } else {
+ this.fire('network-error', {error: err});
+ }
+ }
+ throw err;
+ });
+ }
+
+ /**
+ * Fetch JSON from url provided.
+ * Returns a Promise that resolves to a parsed response.
+ * Same as {@link fetchRawJSON}, plus error handling.
+ * @param {Defs.FetchJSONRequest} req
+ */
+ fetchJSON(req) {
+ 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.fire('server-error', {request: req, response});
+ 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 this.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 this.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 {Defs.FetchJSONRequest} req
+ * @return {Defs.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;
+ }
+
+ getBaseUrl() {
+ return this._restApiInterface.getBaseUrl();
+ }
+
+ fire(type, detail, options) {
+ return this._restApiInterface.fire(type, detail, options);
+ }
+
+ /**
+ * @param {Defs.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 {Defs.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 : this.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.fire('server-error', {request: fetchReq, response});
+ }
+ return response;
+ }).catch(err => {
+ this.fire('network-error', {error: err});
+ 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;
+ }
+
+ checkCredentials() {
+ if (this._credentialCheck.checking) {
+ return;
+ }
+ this._credentialCheck.checking = true;
+ let req = {url: '/accounts/self/detail', reportUrlAsIs: true};
+ req = this.addAcceptJsonHeader(req);
+ // Skip the REST response cache.
+ return this.fetchRawJSON(req).then(res => {
+ if (!res) { return; }
+ if (res.status === 403) {
+ this.fire('auth-error');
+ this._cache.delete('/accounts/self/detail');
+ } else if (res.ok) {
+ return this.getResponseObject(res);
+ }
+ }).then(res => {
+ this._credentialCheck.checking = false;
+ if (res) {
+ this._cache.set('/accounts/self/detail', res);
+ }
+ return res;
+ }).catch(err => {
+ this._credentialCheck.checking = false;
+ if (err && err.message === FAILED_TO_FETCH_ERROR) {
+ this.fire('auth-error');
+ this._cache.delete('/accounts/self/detail');
+ }
+ });
+ }
+
+ /**
+ * @param {string} prefix
+ */
+ invalidateFetchPromisesPrefix(prefix) {
+ this._fetchPromisesCache.invalidatePrefix(prefix);
+ this._cache.invalidatePrefix(prefix);
+ }
+ }
+
+ window.SiteBasedCache = SiteBasedCache;
+ window.FetchPromisesCache = FetchPromisesCache;
+ window.GrRestApiHelper = GrRestApiHelper;
+})(window);
+
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.html b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.html
new file mode 100644
index 0000000..4eaf1bc
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.html
@@ -0,0 +1,177 @@
+<!DOCTYPE html>
+<!--
+@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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-rest-api-helper</title>
+<script src="/test/common-test-setup.js"></script>
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../../test/common-test-setup.html"/>
+<script src="../../../../scripts/util.js"></script>
+<script src="../gr-auth.js"></script>
+<script src="gr-rest-api-helper.js"></script>
+
+<script>void(0);</script>
+
+<script>
+ suite('gr-rest-api-helper tests', () => {
+ let helper;
+ let sandbox;
+ let cache;
+ let fetchPromisesCache;
+
+ setup(() => {
+ sandbox = sinon.sandbox.create();
+ cache = new SiteBasedCache();
+ fetchPromisesCache = new FetchPromisesCache();
+ const credentialCheck = {checking: false};
+
+ window.CANONICAL_PATH = 'testhelper';
+
+ const mockRestApiInterface = {
+ getBaseUrl: sinon.stub().returns(window.CANONICAL_PATH),
+ fire: sinon.stub(),
+ };
+
+ const testJSON = ')]}\'\n{"hello": "bonjour"}';
+ sandbox.stub(window, 'fetch').returns(Promise.resolve({
+ ok: true,
+ text() {
+ return Promise.resolve(testJSON);
+ },
+ }));
+
+ helper = new GrRestApiHelper(cache, Gerrit.Auth, fetchPromisesCache,
+ credentialCheck, mockRestApiInterface);
+ });
+
+ teardown(() => {
+ sandbox.restore();
+ });
+
+ suite('fetchJSON()', () => {
+ test('Sets header to accept application/json', () => {
+ const authFetchStub = sandbox.stub(helper._auth, 'fetch')
+ .returns(Promise.resolve());
+ helper.fetchJSON({url: '/dummy/url'});
+ assert.isTrue(authFetchStub.called);
+ assert.equal(authFetchStub.lastCall.args[1].headers.get('Accept'),
+ 'application/json');
+ });
+
+ test('Use header option accept when provided', () => {
+ const authFetchStub = sandbox.stub(helper._auth, 'fetch')
+ .returns(Promise.resolve());
+ const headers = new Headers();
+ headers.append('Accept', '*/*');
+ const fetchOptions = {headers};
+ helper.fetchJSON({url: '/dummy/url', fetchOptions});
+ assert.isTrue(authFetchStub.called);
+ assert.equal(authFetchStub.lastCall.args[1].headers.get('Accept'),
+ '*/*');
+ });
+ });
+
+ test('JSON prefix is properly removed', done => {
+ helper.fetchJSON({url: '/dummy/url'}).then(obj => {
+ assert.deepEqual(obj, {hello: 'bonjour'});
+ done();
+ });
+ });
+
+ test('cached results', done => {
+ let n = 0;
+ sandbox.stub(helper, 'fetchJSON', () => {
+ return Promise.resolve(++n);
+ });
+ const promises = [];
+ promises.push(helper.fetchCacheURL('/foo'));
+ promises.push(helper.fetchCacheURL('/foo'));
+ promises.push(helper.fetchCacheURL('/foo'));
+
+ Promise.all(promises).then(results => {
+ assert.deepEqual(results, [1, 1, 1]);
+ helper.fetchCacheURL('/foo').then(foo => {
+ assert.equal(foo, 1);
+ done();
+ });
+ });
+ });
+
+ test('cached promise', done => {
+ const promise = Promise.reject(new Error('foo'));
+ cache.set('/foo', promise);
+ helper.fetchCacheURL({url: '/foo'}).catch(p => {
+ assert.equal(p.message, 'foo');
+ done();
+ });
+ });
+
+ test('cache invalidation', () => {
+ cache.set('/foo/bar', 1);
+ cache.set('/bar', 2);
+ fetchPromisesCache.set('/foo/bar', 3);
+ fetchPromisesCache.set('/bar', 4);
+ helper.invalidateFetchPromisesPrefix('/foo/');
+ assert.isFalse(cache.has('/foo/bar'));
+ assert.isTrue(cache.has('/bar'));
+ assert.isUndefined(fetchPromisesCache.get('/foo/bar'));
+ assert.strictEqual(4, fetchPromisesCache.get('/bar'));
+ });
+
+ test('params are properly encoded', () => {
+ let url = helper.urlWithParams('/path/', {
+ sp: 'hola',
+ gr: 'guten tag',
+ noval: null,
+ });
+ assert.equal(url,
+ window.CANONICAL_PATH + '/path/?sp=hola&gr=guten%20tag&noval');
+
+ url = helper.urlWithParams('/path/', {
+ sp: 'hola',
+ en: ['hey', 'hi'],
+ });
+ assert.equal(url, window.CANONICAL_PATH + '/path/?sp=hola&en=hey&en=hi');
+
+ // Order must be maintained with array params.
+ url = helper.urlWithParams('/path/', {
+ l: ['c', 'b', 'a'],
+ });
+ assert.equal(url, window.CANONICAL_PATH + '/path/?l=c&l=b&l=a');
+ });
+
+ test('request callbacks can be canceled', done => {
+ let cancelCalled = false;
+ window.fetch.returns(Promise.resolve({
+ body: {
+ cancel() { cancelCalled = true; },
+ },
+ }));
+ const cancelCondition = () => { return true; };
+ helper.fetchJSON({url: '/dummy/url', cancelCondition}).then(
+ obj => {
+ assert.isUndefined(obj);
+ assert.isTrue(cancelCalled);
+ done();
+ });
+ });
+ });
+</script>
diff --git a/polygerrit-ui/app/template_test_srcs/template_test.js b/polygerrit-ui/app/template_test_srcs/template_test.js
index 3de6227..ac0dbeb 100644
--- a/polygerrit-ui/app/template_test_srcs/template_test.js
+++ b/polygerrit-ui/app/template_test_srcs/template_test.js
@@ -35,6 +35,9 @@
'GrReviewerUpdatesParser',
'GrCountStringFormatter',
'GrThemeApi',
+ 'SiteBasedCache',
+ 'FetchPromisesCache',
+ 'GrRestApiHelper',
'moment',
'page',
'util',
diff --git a/polygerrit-ui/app/test/index.html b/polygerrit-ui/app/test/index.html
index 0fbc8f1..9c06113 100644
--- a/polygerrit-ui/app/test/index.html
+++ b/polygerrit-ui/app/test/index.html
@@ -187,6 +187,7 @@
'shared/gr-rest-api-interface/gr-auth_test.html',
'shared/gr-rest-api-interface/gr-rest-api-interface_test.html',
'shared/gr-rest-api-interface/gr-reviewer-updates-parser_test.html',
+ 'shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.html',
'shared/gr-select/gr-select_test.html',
'shared/gr-storage/gr-storage_test.html',
'shared/gr-textarea/gr-textarea_test.html',