Source: request.js

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;