 e2d84b21ad
			
		
	
	e2d84b21ad
	
	
	
		
			
			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
		
			
				
	
	
		
			457 lines
		
	
	
		
			13 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			457 lines
		
	
	
		
			13 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
| /**
 | |
|  * @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);
 | |
| 
 |