import stampit from 'stampit'; import Promise from 'bluebird'; import superagent from 'superagent'; import _ from 'lodash'; import {ConfigMixin, Logger} from './utils'; import {RequestError} from './errors'; import SyncanoFile from './file'; const IS_NODE = typeof module !== 'undefined' && module.exports && typeof __webpack_require__ === 'undefined'; /** * Base request object **not** meant to be used directly, more like mixin in other {@link https://github.com/stampit-org/stampit|stamps}. * @constructor * @type {Request} * @property {Object} _request * @property {Function} [_request.handler = superagent] * @property {Array} [_request.allowedMethods = ['GET', 'POST', 'DELETE', 'HEAD', 'PUT', 'PATCH']] * @example {@lang javascript} * var MyStamp = stampit().compose(Request); */ const Request = stampit().compose(ConfigMixin, Logger) .refs({ _request: { handler: superagent, allowedMethods: [ 'GET', 'POST', 'DELETE', 'HEAD', 'PUT', 'PATCH' ] } }) .methods({ /** * Sets request handler, used for mocking. * @memberOf Request * @instance * @param {Function} handler * @returns {Request} */ setRequestHandler(handler) { this._request.handler = handler; return this; }, /** * Gets request handler. * @memberOf Request * @instance * @returns {Function} */ getRequestHandler() { return this._request.handler; }, /** * Builds full URL based on path. * @memberOf Request * @instance * @param {String} path path part of URL e.g: /v1.1/instances/ * @returns {String} */ buildUrl(path) { const config = this.getConfig(); if (!_.isString(path)) { return Promise.reject(new Error('"path" needs to be a string.')); } if (_.startsWith(path, config.getBaseUrl())) { return path; } return `${config.getBaseUrl()}${path}`; }, /** * Wrapper around {@link http://visionmedia.github.io/superagent/|superagent} which validates and calls requests. * @memberOf Request * @instance * @param {String} methodName e.g GET, POST * @param {String} path e.g /v1.1/instances/ * @param {Object} requestOptions All options required to build request * @param {String} [requestOptions.type = 'json'] request type e.g form, json, png * @param {String} [requestOptions.accept = 'json'] request accept e.g form, json, png * @param {Number} [requestOptions.timeout = 15000] request timeout * @param {Object} [requestOptions.headers = {}] request headers * @param {Object} [requestOptions.query = {}] request query * @param {Object} [requestOptions.payload = {}] request payload * @returns {Promise} */ makeRequest(methodName, path, requestOptions) { const config = this.getConfig(); let method = (methodName || '').toUpperCase(); let options = _.defaults({}, requestOptions, { type: 'json', accept: 'json', timeout: 15000, headers: {}, query: {}, payload: {}, responseAttr: 'body' }); if (_.isEmpty(methodName) || !_.includes(this._request.allowedMethods, method)) { return Promise.reject(new Error(`Invalid request method: "${methodName}".`)); } if (_.isEmpty(path)) { return Promise.reject(new Error('"path" is required.')); } if (!_.isUndefined(config)) { if (!_.isEmpty(config.getAccountKey())) { options.headers['X-API-KEY'] = config.getAccountKey(); } // Yes, we will replace account key if (!_.isEmpty(config.getApiKey())) { options.headers['X-API-KEY'] = config.getApiKey(); } if (!_.isEmpty(config.getUserKey())) { options.headers['X-USER-KEY'] = config.getUserKey(); } if (!_.isEmpty(config.getSocialToken())) { options.headers['Authorization'] = `Token ${config.getSocialToken()}`; } } // Grab files const files = _.reduce(options.payload, (result, value, key) => { if (value instanceof SyncanoFile) { result[key] = value; } return result; }, {}); let handler = this.getRequestHandler(); let request = handler(method, this.buildUrl(path)) .accept(options.accept) .timeout(options.timeout) .set(options.headers) .query(options.query); if (_.isEmpty(files)) { request = request .type(options.type) .send(options.payload); } else if (IS_NODE === false && typeof FormData !== 'undefined' && typeof File !== 'undefined') { options.type = null; options.payload = _.reduce(options.payload, (formData, value, key) => { formData.append(key, (files[key]) ? value.content: value); return formData; }, new FormData()); request = request .type(options.type) .send(options.payload); } else if (IS_NODE === true) { request = _.reduce(options.payload, (result, value, key) => { if(!_.isFunction(value)) { return (files[key]) ? result.attach(key, value.content): result.field(key, value); } return result; }, request.type('form')); } request.on('progress', (e) => { if(_.isFunction(this.getConfig().progressCallback)) { this.getConfig().progressCallback(e); } }); return Promise.promisify(request.end, {context: request})() .then((response) => { if (!response.ok) { return Promise.reject(new RequestError({ response: response, status: response.status, message: 'Bad request' })); } return response[options.responseAttr]; }) .catch((err) => { if (err.status && err.response) { if(err.status === 429) { const delay = _.toNumber(err.response.headers['retry-after']) * 1000; return Promise.delay(delay) .then(() => this.makeRequest(methodName, path, requestOptions)); } this.log(`\n${method} ${path}\n${JSON.stringify(options, null, 2)}\n`); this.log(`Response ${err.status}:`, err.errors); if (err.name !== 'RequestError') { return Promise.reject(new RequestError(err, err.response)); } } throw err; }); } }).static({ /** * Sets request handler and returns new {@link https://github.com/stampit-org/stampit|stampit} object, used for mocking. * @memberOf Request * @static * @returns {stampit} */ setRequestHandler(handler) { let _request = this.fixed.refs._request || {}; _request.handler = handler; return this.refs({_request}); }, /** * Sets request handler from {@link https://github.com/stampit-org/stampit|stampit} definition. * @memberOf Request * @static * @returns {Function} */ getRequestHandler() { return this.fixed.refs._request.handler; } }); export default Request;