topcoder-react-lib
Version:
The implementation of TC lib for ReactJS projects
293 lines (264 loc) • 8.37 kB
JavaScript
/**
* @module "services.api"
* @desc This module provides a service for conventient access to Topcoder APIs.
*/
import _ from 'lodash';
import fetch from 'isomorphic-fetch';
import { config, isomorphy } from 'topcoder-react-utils';
import { auth } from 'tc-core-library-js';
import { delay } from '../utils/time';
import {
setErrorIcon,
ERROR_ICON_TYPES,
} from '../utils/errors';
const { m2m: m2mAuth } = auth;
const m2m = m2mAuth(_.pick(config.AUTH_CONFIG, ['AUTH0_URL', 'AUTH0_AUDIENCE', 'TOKEN_CACHE_TIME', 'AUTH0_PROXY_SERVER_URL']));
/* The minimal delay [ms] between API calls. To avoid problems with the requests
* rate limits configured in Topcoder APIs, we throttle requests rate at the
* client side, and at server-side, in dev mode (which is meant to be used for
* local development. */
const MIN_API_CALL_DELAY = isomorphy.isDevBuild() ? 1000 : 200;
const API_THROTTLING = true;
let lastApiCallTimestamp = Date.now();
/**
* @static
* @member default
* @desc The default export from the module is
* {@link module:services.api~Api} class.
*/
/**
* API service object. It is reused for both Topcoder API v2 and v3,
* as in these cases we are fine with the same interface, and the only
* thing we need to be different is the base URL and auth token to use.
*/
class Api {
/**
* Creates a new Api object.
* @param {String} base Base URL of the API.
* @param {String} token Optional. Authorization token.
*/
constructor(base, token) {
this.private = {
base,
token,
};
}
/**
* Sends HTTP request to the specified API endpoint. This method is just
* a convenient wrapper around isomorphic fetch(..):
*
* - If API service has auth token, Authorization header is automatically
* added to the request;
*
* - If no Content-Type header set in options, it is automatically set to
* "application/json". In case you want to avoid it, pass null into
* Content-Type header option.
*
* For additional details see https://github.github.io/fetch/
* @param {String} enpoint Should start with slash, like /endpoint.
* @param {Object} options Optional. Fetch options.
* @return {Promise} It resolves to the HTTP response object. To get the
* actual data you probably want to call .json() method of that object.
* Mind that this promise rejects only on network errors. In case of
* HTTP errors (404, etc.) the promise will be resolved successfully,
* and you should check .status or .ok fields of the response object
* to find out the response status.
*/
async fetch(endpoint, options = {}) {
const {
base,
token,
} = this.private;
const headers = options.headers ? _.clone(options.headers) : {};
if (token) headers.Authorization = `Bearer ${token}`;
switch (headers['Content-Type']) {
case null:
delete headers['Content-Type'];
break;
case undefined:
headers['Content-Type'] = 'application/json';
break;
default:
}
/* Throttling of API calls should not happen at server in production. */
if (API_THROTTLING && (isomorphy.isClientSide() || isomorphy.isDevBuild())) {
const now = Date.now();
lastApiCallTimestamp += MIN_API_CALL_DELAY;
if (lastApiCallTimestamp > now) {
await delay(lastApiCallTimestamp - now);
} else lastApiCallTimestamp = now;
}
return fetch(`${base}${endpoint}`, {
...options,
headers,
})
.catch((e) => {
setErrorIcon(ERROR_ICON_TYPES.NETWORK, `${base}${endpoint}`, e.message);
throw e;
});
}
/**
* Sends DELETE request to the specified endpoint.
* @param {String} endpoint
* @param {Blob|BufferSource|FormData|String} body
* @return {Promise}
*/
delete(endpoint, body) {
return this.fetch(endpoint, {
body,
method: 'DELETE',
});
}
/**
* Sends GET request to the specified endpoint.
* @param {String} endpoint
* @return {Promise}
*/
get(endpoint, options = {}) {
return this.fetch(endpoint, options);
}
/**
* Sends POST request to the specified endpoint.
* @param {String} endpoint
* @param {Blob|BufferSource|FormData|String} body
* @return {Promise}
*/
post(endpoint, body) {
return this.fetch(endpoint, {
body,
method: 'POST',
});
}
/**
* Sends POST request to the specified endpoint, with JSON payload.
* @param {String} endpoint
* @param {JSON} json
* @return {Promise}
*/
postJson(endpoint, json) {
return this.post(endpoint, JSON.stringify(json));
}
/**
* Sends PUT request to the specified endpoint.
* @param {String} endpoint
* @param {Blob|BufferSource|FormData|String} body
* @return {Promise}
*/
put(endpoint, body) {
return this.fetch(endpoint, {
body,
method: 'PUT',
});
}
/**
* Sends PUT request to the specified endpoint.
* @param {String} endpoint
* @param {JSON} json
* @return {Promise}
*/
putJson(endpoint, json) {
return this.put(endpoint, JSON.stringify(json));
}
/**
* Sends PATCH request to the specified endpoint.
* @param {String} endpoint
* @param {Blob|BufferSource|FormData|String} body
* @return {Promise}
*/
patch(endpoint, body) {
return this.fetch(endpoint, {
body,
method: 'PATCH',
});
}
/**
* Sends PATCH request to the specified endpoint.
* @param {String} endpoint
* @param {JSON} json
* @return {Promise}
*/
patchJson(endpoint, json) {
return this.patch(endpoint, JSON.stringify(json));
}
/**
* Upload with progress
* @param {String} endpoint
* @param {Object} body and headers
* @param {Function} callback handler for update progress only works for client side for now
* @return {Promise}
*/
upload(endpoint, options, onProgress) {
const {
base,
token,
} = this.private;
const headers = options.headers ? _.clone(options.headers) : {};
if (token) headers.Authorization = `Bearer ${token}`;
if (isomorphy.isClientSide()) {
return new Promise((res, rej) => {
const xhr = new XMLHttpRequest(); //eslint-disable-line
xhr.open(options.method, `${base}${endpoint}`);
Object.keys(headers).forEach((key) => {
if (headers[key] != null) {
xhr.setRequestHeader(key, headers[key]);
}
});
xhr.onload = e => res(e.target.responseText);
xhr.onerror = rej;
if (xhr.upload && onProgress) {
xhr.upload.onprogress = (evt) => {
if (evt.lengthComputable) onProgress(evt.loaded / evt.total);
};
}
xhr.send(options.body);
});
}
return this.fetch(endpoint, options);
}
}
export default Api;
/*
* Topcoder API
*/
const lastApiInstances = {};
/**
* Returns a new or existing Api object for Topcoder API.
* @param {String} version The API version.
* @param {String} token Optional. Auth token for Topcoder API.
* @return {Api} API service object.
*/
export function getApi(version, token) {
if (!version || !config.API[version]) {
throw new Error(`${version} is not a valid API version`);
}
if (!lastApiInstances[version] || lastApiInstances[version].private.token !== token) {
lastApiInstances[version] = new Api(config.API[version], token);
}
return lastApiInstances[version];
}
/**
* Keep the old API factories for backwards compatibility
* DO NOT USE THEM FOR NEW IMPLEMENTATIONS.
* USE THE getApi(version, token) FACTORY.
*/
export const getApiV2 = token => getApi('V2', token);
export const getApiV3 = token => getApi('V3', token);
export const getApiV4 = token => getApi('V4', token);
export const getApiV5 = token => getApi('V5', token);
/**
* Gets a valid TC M2M token, either requesting one from TC Auth0 API, or
* serving one from internal cache.
*
* @return {Promise} Resolves to a token, valid at least next
* getTcM2mToken.MIN_LIFETIME milliseconds.
*
* @throw if called outside of the server.s
*/
export async function getTcM2mToken() {
if (!isomorphy.isServerSide()) {
throw new Error('getTcM2mToken() called outside the server');
}
const { TC_M2M } = config.SECRET;
const token = await m2m.getMachineToken(TC_M2M.CLIENT_ID, TC_M2M.CLIENT_SECRET);
return token;
}