blob: d42abc351daecba1a1ce27b2ad282ea60ff23e8b [file] [log] [blame]
/**
* @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);