client.js

'use strict';

const compose = require('koa-compose');
const context = require('./context');
const delayBy = require('./delayedPromise');
const packageInfo = require('../package');
const bind = require('./bind');

const RETRY_DELAY = 100;

/** @class */
class HttpTransport {
  constructor(httpTransport) {
    this._httpTransport = httpTransport;
    this._ctx = context.create();
    this._instancePlugins = [];
    this._defaultHeaders = {
      'User-Agent': `${packageInfo.name}/${packageInfo.version}`
    };
    this.headers(this._defaultHeaders);
    bind(this);
  }

  /**
   * Registers a global plugin, which is used for all requests
   *
   * @method
   * useGlobal
   * @param {function} fn - a global plugin
   * @return a HttpTransport instance
   * @example
   * const toError = require('http-transport-errors'); 
   * const httpTransport = require('http-transport');
   *  
   * const client = httpTransport.createClient();
   * client.useGlobal(toError(404));
   */
  useGlobal(plugin) {
    validatePlugin(plugin);
    this._instancePlugins.push(plugin);
    return this;
  }

  /**
   * Registers a per request plugin
   *
   * @method
   * use
   * @return a HttpTransport instance
   * @param {function} fn - per request plugin
   * @example
   * const toError = require('http-transport-errors'); 
   * const httpTransport = require('http-transport');
   *  
   * httpTransport.createClient()
   *    .use(toError(404));
   */
  use(plugin) {
    validatePlugin(plugin);
    this._ctx.addPlugin(plugin);
    return this;
  }

  /**
   * Make a HTTP GET request
   *
   * @method
   * get
   * @param {string} url
   * @return a HttpTransport instance
   * @example
   * const httpTransport = require('http-transport');
   *  
   * const response = httpTransport.createClient()
   *    .get(url)
   *    .asResponse();
   */
  get(url) {
    this._ctx.req
      .method('GET')
      .url(url);
    return this;
  }

  /**
   * Make a HTTP POST request
   *
   * @method
   * post
   * @param {string} url
   * @param {object} request body
   * @return a HttpTransport instance
   * @example
   * const httpTransport = require('http-transport');
   *  
   * const response = httpTransport.createClient()
   *    .post(url, requestBody)
   *    .asResponse();
   */
  post(url, body) {
    this._ctx.req
      .method('POST')
      .body(body)
      .url(url);
    return this;
  }

  /**
   * Make a HTTP PUT request
   *
   * @method
   * post
   * @param {string} url
   * @param {object} request body
   * @return a HttpTransport instance
   * @example
   * const httpTransport = require('http-transport');
   *  
   * const response = httpTransport.createClient()
   *    .put(url, requestBody)
   *    .asResponse();
   */
  put(url, body) {
    this._ctx.req
      .method('PUT')
      .body(body)
      .url(url);
    return this;
  }

  /**
   * Make a HTTP DELETE request
   *
   * @method
   * post
   * @param {string} url
   * @param {object} request body
   * @return a HttpTransport instance
   * @example
   * const httpTransport = require('http-transport');
   *  
   * const response = httpTransport.createClient()
   *    .delete(url)
   *    .asResponse();
   */
  delete(url) {
    this._ctx.req
      .method('DELETE')
      .url(url);
    return this;
  }

  /**
   * Make a HTTP PATCH request
   *
   * @method
   * post
   * @param {string} url
   * @param {object} request body
   * @return a HttpTransport instance
   * @example
   * const httpTransport = require('http-transport');
   *  
   * const response = httpTransport.createClient()
   *    .put(url, requestBody)
   *    .asResponse();
   */
  patch(url, body) {
    this._ctx.req
      .method('PATCH')
      .body(body)
      .url(url);
    return this;
  }

  /**
   * Make a HTTP HEAD request
   *
   * @method
   * head
   * @param {string} url
   * @return a HttpTransport instance
   * @example
   * const httpTransport = require('http-transport');
   *  
   * const response = httpTransport.createClient()
   *    .head(url)
   *    .asResponse();
   */
  head(url) {
    this._ctx.req
      .method('HEAD')
      .url(url);
    return this;
  }

  /**
   * Sets the request headers
   *
   * @method
   * headers
   * @param {string|object} name - header name or headers object
   * @param {string|object} value - header value
   * @return a HttpTransport instance
   * @example
   * const httpTransport = require('http-transport');
   *  
   * const response = httpTransport.createClient()
   *    .headers({
   *      'User-Agent' : 'someUserAgent' 
   *    })
   *    .asResponse();
   */
  headers() {
    rejectIfEmpty(arguments, 'missing headers');

    const args = normalise(arguments);
    Object.keys(args).forEach((key) => {
      this._ctx.req.addHeader(key, args[key]);
    });
    return this;
  }

  /**
   * Sets the query strings
   *
   * @method
   * query
   * @param {string|object} name - query name or query object
   * @param {string|object} value - query value
   * @return a HttpTransport instance
   * @example
   * const httpTransport = require('http-transport');
   *  
   * const response = httpTransport.createClient()
   *    .query({
   *      'perPage' : 1 
   *    })
   *    .asResponse();
   */
  query() {
    rejectIfEmpty(arguments, 'missing query strings');

    const args = normalise(arguments);
    Object.keys(args).forEach((key) => {
      this._ctx.req.addQuery(key, args[key]);
    });
    return this;
  }

  /**
   * Sets a request timeout
   *
   * @method
   * timeout
   * @param {integer} timeout - timeout in seconds
   * @return a HttpTransport instance
   * @example
   * const httpTransport = require('http-transport');
   *  
   * const response = httpTransport.createClient()
   *    .timeout(1)
   *    .asResponse();
   */
  timeout(secs) {
    this._ctx.req.timeout(secs);
    return this;
  }

  /**
   * Set the number of retries on failure
   *
   * @method
   * retry
   * @param {integer} timeout - number of times to retry a failed request
   * @return a HttpTransport instance
   * @example
   * const httpTransport = require('http-transport');
   *  
   * const response = httpTransport.createClient()
   *    .retry(5)
   *    .asResponse();
   */
  retry(retries) {
    this._retries = retries;
    return this;
  }

  /**
   * Initiates the request, returning the response body, if successful. 
   *
   * @method
   * asBody
   * @return a Promise. If the Promise fulfils,
   * the fulfilment value is the response body, as a string by default. 
   * @example
   * const httpTransport = require('http-transport');
   *  
   * const response = httpTransport.createClient()
   *    .asBody()
   *    .then((body) => {
   *      console.log(body);
   *    });
   */
  asBody() {
    return this.asResponse().then((res) => res.body);
  }

  /**
   * Initiates the request, returning a http transport response object, if successful. 
   *
   * @method
   * asResponse
   * @return a Promise. If the Promise fulfils,
   * the fulfilment value is response object. 
   * @example
   * const httpTransport = require('http-transport');
   *  
   * const response = httpTransport.createClient()
   *    .asResponse()
   *    .then((body) => {
   *      console.log(body);
   *    });
   */
  asResponse() {
    const requestContext = this._ctx;
    this._ctx = context.create();

    return retry(this._executeRequest, requestContext, this._retries).then((ctx) => ctx.res);
  }

  _getPlugins(ctx) {
    return this._instancePlugins.concat(ctx.plugins);
  }

  _applyPlugins(ctx, next) {
    const fn = compose(this._getPlugins(ctx));
    return fn(ctx, next);
  }

  _executeRequest(ctx) {
    return this._applyPlugins(ctx, this._handleRequest.bind(this)).then(() => ctx);
  }

  _handleRequest(ctx, next) {
    return this._httpTransport.createRequest(ctx)
      .execute()
      .then(() => next());
  }
}

function toRetry(err) {
  return {
    reason: err.message,
    statusCode: err.statusCode
  };
}

function retry(fn, ctx, times) {
  ctx.res.retries = [];
  ctx.res.maxAttempts = times;
  const request = fn.bind(this, ctx);

  const attempt = (i) => {
    return request()
      .catch(delayBy(RETRY_DELAY))
      .catch((err) => {
        if (i < times) {
          ctx.res.retries.push(toRetry(err));
          return attempt(++i);
        }
        throw err;
      });
  };
  return attempt(0);
}

function toObject(arr) {
  const obj = {};
  for (let i = 0; i < arr.length; i += 2) {
    obj[arr[i]] = arr[i + 1];
  }
  return obj;
}

function isObject(value) {
  return value !== null && typeof value === 'object';
}

function normalise(args) {
  args = Array.from(args);
  if (isObject(args[0])) {
    return args[0];
  }
  return toObject(args);
}

function isEmptyHeadersObject(args) {
  return (isObject(args[0])) && Object.keys(args[0]).length === 0;
}

function rejectIfEmpty(args, message) {
  if (args.length === 0 || isEmptyHeadersObject(args)) throw new Error(message);
}

function validatePlugin(plugin) {
  if (typeof plugin !== 'function') throw new TypeError('Plugin is not a function');
}

module.exports = HttpTransport;