/* * Copyright 2017-2017 Amazon.com, Inc. or its affiliates. All Rights Reserved. * * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance with * the License. A copy of the License is located at * * http://aws.amazon.com/apache2.0/ * * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR * CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions * and limitations under the License. */ import { ConsoleLogger as Logger, Credentials, DateUtils, Signer, Platform, } from 'nono-aws-amplify/core'; import { apiOptions, ApiInfo } from './types'; import axios, { CancelTokenSource } from 'axios'; import { parse, format } from 'url'; const logger = new Logger('RestClient'); /** * HTTP Client for REST requests. Send and receive JSON data. * Sign request with AWS credentials if available * Usage:
const restClient = new RestClient();
restClient.get('...')
    .then(function(data) {
        console.log(data);
    })
    .catch(err => console.log(err));
*/ export class RestClient { private _options; private _region: string = 'us-east-1'; // this will be updated by endpoint function private _service: string = 'execute-api'; // this can be updated by endpoint function private _custom_header = undefined; // this can be updated by endpoint function /** * This weak map provides functionality to let clients cancel * in-flight axios requests. https://github.com/axios/axios#cancellation * * 1. For every axios request, a unique cancel token is generated and added in the request. * 2. Promise for fulfilling the request is then mapped to that unique cancel token. * 3. The promise is returned to the client. * 4. Clients can either wait for the promise to fulfill or call `API.cancel(promise)` to cancel the request. * 5. If `API.cancel(promise)` is called, then the corresponding cancel token is retrieved from the map below. * 6. Promise returned to the client will be in rejected state with the error provided during cancel. * 7. Clients can check if the error is because of cancelling by calling `API.isCancel(error)`. * * For more details, see https://github.com/aws-amplify/amplify-js/pull/3769#issuecomment-552660025 */ private _cancelTokenMap: WeakMap = null; Credentials = Credentials; /** * @param {RestClientOptions} [options] - Instance options */ constructor(options: apiOptions) { this._options = options; logger.debug('API Options', this._options); if (this._cancelTokenMap == null) { this._cancelTokenMap = new WeakMap(); } } /** * Update AWS credentials * @param {AWSCredentials} credentials - AWS credentials * updateCredentials(credentials: AWSCredentials) { this.options.credentials = credentials; } */ /** * Basic HTTP request. Customizable * @param {string | ApiInfo } urlOrApiInfo - Full request URL or Api information * @param {string} method - Request HTTP method * @param {json} [init] - Request extra params * @return {Promise} - A promise that resolves to an object with response status and JSON data, if successful. */ async ajax(urlOrApiInfo: string | ApiInfo, method: string, init) { logger.debug(method, urlOrApiInfo); let parsed_url; let url: string; let region: string = 'us-east-1'; let service: string = 'execute-api'; let custom_header: () => { [key: string]: string; } = undefined; if (typeof urlOrApiInfo === 'string') { parsed_url = this._parseUrl(urlOrApiInfo); url = urlOrApiInfo; } else { ({ endpoint: url, custom_header, region, service } = urlOrApiInfo); parsed_url = this._parseUrl(urlOrApiInfo.endpoint); } const params = { method, url, host: parsed_url.host, path: parsed_url.path, headers: {}, data: null, responseType: 'json', timeout: 0, cancelToken: null, }; let libraryHeaders = {}; if (Platform.isReactNative) { const userAgent = Platform.userAgent || 'aws-amplify/0.1.x'; libraryHeaders = { 'User-Agent': userAgent, }; } const initParams = Object.assign({}, init); const isAllResponse = initParams.response; if (initParams.body) { if ( typeof FormData === 'function' && initParams.body instanceof FormData ) { libraryHeaders['Content-Type'] = 'multipart/form-data'; params.data = initParams.body; } else { libraryHeaders['Content-Type'] = 'application/json; charset=UTF-8'; params.data = JSON.stringify(initParams.body); } } if (initParams.responseType) { params.responseType = initParams.responseType; } if (initParams.withCredentials) { params['withCredentials'] = initParams.withCredentials; } if (initParams.timeout) { params.timeout = initParams.timeout; } if (initParams.cancellableToken) { params.cancelToken = initParams.cancellableToken.token; } params['signerServiceInfo'] = initParams.signerServiceInfo; // custom_header callback const custom_header_obj = typeof custom_header === 'function' ? await custom_header() : undefined; params.headers = { ...libraryHeaders, ...custom_header_obj, ...initParams.headers, }; // Intentionally discarding search const { search, ...parsedUrl } = parse(url, true, true); params.url = format({ ...parsedUrl, query: { ...parsedUrl.query, ...(initParams.queryStringParameters || {}), }, }); // Do not sign the request if client has added 'Authorization' header, // which means custom authorizer. if (typeof params.headers['Authorization'] !== 'undefined') { params.headers = Object.keys(params.headers).reduce((acc, k) => { if (params.headers[k]) { acc[k] = params.headers[k]; } return acc; // tslint:disable-next-line:align }, {}); return this._request(params, isAllResponse); } // Signing the request in case there credentials are available return this.Credentials.get().then( credentials => { return this._signed({ ...params }, credentials, isAllResponse, { region, service, }).catch(error => { if (DateUtils.isClockSkewError(error)) { const { headers } = error.response; const dateHeader = headers && (headers.date || headers.Date); const responseDate = new Date(dateHeader); const requestDate = DateUtils.getDateFromHeaderString( params.headers['x-amz-date'] ); // Compare local clock to the server clock if (DateUtils.isClockSkewed(responseDate)) { DateUtils.setClockOffset( responseDate.getTime() - requestDate.getTime() ); return this.ajax(urlOrApiInfo, method, init); } } throw error; }); }, err => { logger.debug('No credentials available, the request will be unsigned'); return this._request(params, isAllResponse); } ); } /** * GET HTTP request * @param {string | ApiInfo } urlOrApiInfo - Full request URL or Api information * @param {JSON} init - Request extra params * @return {Promise} - A promise that resolves to an object with response status and JSON data, if successful. */ get(urlOrApiInfo: string | ApiInfo, init) { return this.ajax(urlOrApiInfo, 'GET', init); } /** * PUT HTTP request * @param {string | ApiInfo } urlOrApiInfo - Full request URL or Api information * @param {json} init - Request extra params * @return {Promise} - A promise that resolves to an object with response status and JSON data, if successful. */ put(urlOrApiInfo: string | ApiInfo, init) { return this.ajax(urlOrApiInfo, 'PUT', init); } /** * PATCH HTTP request * @param {string | ApiInfo } urlOrApiInfo - Full request URL or Api information * @param {json} init - Request extra params * @return {Promise} - A promise that resolves to an object with response status and JSON data, if successful. */ patch(urlOrApiInfo: string | ApiInfo, init) { return this.ajax(urlOrApiInfo, 'PATCH', init); } /** * POST HTTP request * @param {string | ApiInfo } urlOrApiInfo - Full request URL or Api information * @param {json} init - Request extra params * @return {Promise} - A promise that resolves to an object with response status and JSON data, if successful. */ post(urlOrApiInfo: string | ApiInfo, init) { return this.ajax(urlOrApiInfo, 'POST', init); } /** * DELETE HTTP request * @param {string | ApiInfo } urlOrApiInfo - Full request URL or Api information * @param {json} init - Request extra params * @return {Promise} - A promise that resolves to an object with response status and JSON data, if successful. */ del(urlOrApiInfo: string | ApiInfo, init) { return this.ajax(urlOrApiInfo, 'DELETE', init); } /** * HEAD HTTP request * @param {string | ApiInfo } urlOrApiInfo - Full request URL or Api information * @param {json} init - Request extra params * @return {Promise} - A promise that resolves to an object with response status and JSON data, if successful. */ head(urlOrApiInfo: string | ApiInfo, init) { return this.ajax(urlOrApiInfo, 'HEAD', init); } /** * Cancel an inflight API request * @param {Promise} request - The request promise to cancel * @param {string} [message] - A message to include in the cancelation exception */ cancel(request: Promise, message?: string) { const source = this._cancelTokenMap.get(request); if (source) { source.cancel(message); return true; } return false; } /** * Check if the request has a corresponding cancel token in the WeakMap. * @params request - The request promise * @return if the request has a corresponding cancel token. */ hasCancelToken(request: Promise) { return this._cancelTokenMap.has(request); } /** * Checks to see if an error thrown is from an api request cancellation * @param {any} error - Any error * @return {boolean} - A boolean indicating if the error was from an api request cancellation */ isCancel(error): boolean { return axios.isCancel(error); } /** * Retrieves a new and unique cancel token which can be * provided in an axios request to be cancelled later. */ getCancellableToken(): CancelTokenSource { return axios.CancelToken.source(); } /** * Updates the weakmap with a response promise and its * cancel token such that the cancel token can be easily * retrieved (and used for cancelling the request) */ updateRequestToBeCancellable( promise: Promise, cancelTokenSource: CancelTokenSource ) { this._cancelTokenMap.set(promise, cancelTokenSource); } /** * Getting endpoint for API * @param {string} apiName - The name of the api * @return {string} - The endpoint of the api */ endpoint(apiName: string) { const cloud_logic_array = this._options.endpoints; let response = ''; if (!Array.isArray(cloud_logic_array)) { return response; } cloud_logic_array.forEach(v => { if (v.name === apiName) { response = v.endpoint; if (typeof v.region === 'string') { this._region = v.region; } else if (typeof this._options.region === 'string') { this._region = this._options.region; } if (typeof v.service === 'string') { this._service = v.service || 'execute-api'; } else { this._service = 'execute-api'; } if (typeof v.custom_header === 'function') { this._custom_header = v.custom_header; } else { this._custom_header = undefined; } } }); return response; } /** private methods **/ private _signed(params, credentials, isAllResponse, { service, region }) { const { signerServiceInfo: signerServiceInfoParams, ...otherParams } = params; const endpoint_region: string = region || this._region || this._options.region; const endpoint_service: string = service || this._service || this._options.service; const creds = { secret_key: credentials.secretAccessKey, access_key: credentials.accessKeyId, session_token: credentials.sessionToken, }; const endpointInfo = { region: endpoint_region, service: endpoint_service, }; const signerServiceInfo = Object.assign( endpointInfo, signerServiceInfoParams ); const signed_params = Signer.sign(otherParams, creds, signerServiceInfo); if (signed_params.data) { signed_params.body = signed_params.data; } logger.debug('Signed Request: ', signed_params); delete signed_params.headers['host']; return axios(signed_params) .then(response => (isAllResponse ? response : response.data)) .catch(error => { logger.debug(error); throw error; }); } private _request(params, isAllResponse = false) { return axios(params) .then(response => (isAllResponse ? response : response.data)) .catch(error => { logger.debug(error); throw error; }); } private _parseUrl(url) { const parts = url.split('/'); return { host: parts[2], path: '/' + parts.slice(3).join('/'), }; } }