Requestable.js

/**
 * @file
 * @copyright  2016 Yahoo Inc.
 * @license    Licensed under {@link https://spdx.org/licenses/BSD-3-Clause-Clear.html BSD-3-Clause-Clear}.
 *             Github.js is freely distributable.
 */

import axios from 'axios';
import debug from 'debug';
import {Base64} from 'js-base64';

const log = debug('github:request');

/**
 * The error structure returned when a network call fails
 */
class ResponseError extends Error {
   /**
    * Construct a new ResponseError
    * @param {string} message - an message to return instead of the the default error message
    * @param {string} path - the requested path
    * @param {Object} response - the object returned by Axios
    */
   constructor(message, path, response) {
      super(message);
      this.path = path;
      this.request = response.config;
      this.response = (response || {}).response || response;
      this.status = response.status;
   }
}

/**
 * Requestable wraps the logic for making http requests to the API
 */
class Requestable {
   /**
    * Either a username and password or an oauth token for Github
    * @typedef {Object} Requestable.auth
    * @prop {string} [username] - the Github username
    * @prop {string} [password] - the user's password
    * @prop {token} [token] - an OAuth token
    */
   /**
    * Initialize the http internals.
    * @param {Requestable.auth} [auth] - the credentials to authenticate to Github. If auth is
    *                                  not provided request will be made unauthenticated
    * @param {string} [apiBase=https://api.github.com] - the base Github API URL
    * @param {string} [AcceptHeader=v3] - the accept header for the requests
    */
   constructor(auth, apiBase, AcceptHeader) {
      this.__apiBase = apiBase || 'https://api.github.com';
      this.__auth = {
         token: auth.token,
         username: auth.username,
         password: auth.password,
      };
      this.__AcceptHeader = AcceptHeader || 'v3';

      if (auth.token) {
         this.__authorizationHeader = 'token ' + auth.token;
      } else if (auth.username && auth.password) {
         this.__authorizationHeader = 'Basic ' + Base64.encode(auth.username + ':' + auth.password);
      }
   }

   /**
    * Compute the URL to use to make a request.
    * @private
    * @param {string} path - either a URL relative to the API base or an absolute URL
    * @return {string} - the URL to use
    */
   __getURL(path) {
      let url = path;

      if (path.indexOf('//') === -1) {
         url = this.__apiBase + path;
      }

      let newCacheBuster = 'timestamp=' + new Date().getTime();
      return url.replace(/(timestamp=\d+)/, newCacheBuster);
   }

   /**
    * Compute the headers required for an API request.
    * @private
    * @param {boolean} raw - if the request should be treated as JSON or as a raw request
    * @param {string} AcceptHeader - the accept header for the request
    * @return {Object} - the headers to use in the request
    */
   __getRequestHeaders(raw, AcceptHeader) {
      let headers = {
         'Content-Type': 'application/json;charset=UTF-8',
         'Accept': 'application/vnd.github.' + (AcceptHeader || this.__AcceptHeader),
      };

      if (raw) {
         headers.Accept += '.raw';
      }
      headers.Accept += '+json';

      if (this.__authorizationHeader) {
         headers.Authorization = this.__authorizationHeader;
      }

      return headers;
   }

   /**
    * Sets the default options for API requests
    * @protected
    * @param {Object} [requestOptions={}] - the current options for the request
    * @return {Object} - the options to pass to the request
    */
   _getOptionsWithDefaults(requestOptions = {}) {
      if (!(requestOptions.visibility || requestOptions.affiliation)) {
         requestOptions.type = requestOptions.type || 'all';
      }
      requestOptions.sort = requestOptions.sort || 'updated';
      requestOptions.per_page = requestOptions.per_page || '100'; // eslint-disable-line

      return requestOptions;
   }

   /**
    * if a `Date` is passed to this function it will be converted to an ISO string
    * @param {*} date - the object to attempt to cooerce into an ISO date string
    * @return {string} - the ISO representation of `date` or whatever was passed in if it was not a date
    */
   _dateToISO(date) {
      if (date && (date instanceof Date)) {
         date = date.toISOString();
      }

      return date;
   }

   /**
    * A function that receives the result of the API request.
    * @callback Requestable.callback
    * @param {Requestable.Error} error - the error returned by the API or `null`
    * @param {(Object|true)} result - the data returned by the API or `true` if the API returns `204 No Content`
    * @param {Object} request - the raw {@linkcode https://github.com/mzabriskie/axios#response-schema Response}
    */
   /**
    * Make a request.
    * @param {string} method - the method for the request (GET, PUT, POST, DELETE)
    * @param {string} path - the path for the request
    * @param {*} [data] - the data to send to the server. For HTTP methods that don't have a body the data
    *                   will be sent as query parameters
    * @param {Requestable.callback} [cb] - the callback for the request
    * @param {boolean} [raw=false] - if the request should be sent as raw. If this is a falsy value then the
    *                              request will be made as JSON
    * @return {Promise} - the Promise for the http request
    */
   _request(method, path, data, cb, raw) {
      const url = this.__getURL(path);

      const AcceptHeader = (data || {}).AcceptHeader;
      if (AcceptHeader) {
         delete data.AcceptHeader;
      }
      const headers = this.__getRequestHeaders(raw, AcceptHeader);

      let queryParams = {};

      const shouldUseDataAsParams = data && (typeof data === 'object') && methodHasNoBody(method);
      if (shouldUseDataAsParams) {
         queryParams = data;
         data = undefined;
      }

      const config = {
         url: url,
         method: method,
         headers: headers,
         params: queryParams,
         data: data,
         responseType: raw ? 'text' : 'json',
      };

      log(`${config.method} to ${config.url}`);
      const requestPromise = axios(config).catch(callbackErrorOrThrow(cb, path));

      if (cb) {
         requestPromise.then((response) => {
            if (response.data && Object.keys(response.data).length > 0) {
               // When data has results
               cb(null, response.data, response);
            } else if (config.method !== 'GET' && Object.keys(response.data).length < 1) {
               // True when successful submit a request and receive a empty object
               cb(null, (response.status < 300), response);
            } else {
               cb(null, response.data, response);
            }
         });
      }

      return requestPromise;
   }

   /**
    * Make a request to an endpoint the returns 204 when true and 404 when false
    * @param {string} path - the path to request
    * @param {Object} data - any query parameters for the request
    * @param {Requestable.callback} cb - the callback that will receive `true` or `false`
    * @param {method} [method=GET] - HTTP Method to use
    * @return {Promise} - the promise for the http request
    */
   _request204or404(path, data, cb, method = 'GET') {
      return this._request(method, path, data)
         .then(function success(response) {
            if (cb) {
               cb(null, true, response);
            }
            return true;
         }, function failure(response) {
            if (response.response.status === 404) {
               if (cb) {
                  cb(null, false, response);
               }
               return false;
            }

            if (cb) {
               cb(response);
            }
            throw response;
         });
   }

   /**
    * Make a request and fetch all the available data. Github will paginate responses so for queries
    * that might span multiple pages this method is preferred to {@link Requestable#request}
    * @param {string} path - the path to request
    * @param {Object} options - the query parameters to include
    * @param {Requestable.callback} [cb] - the function to receive the data. The returned data will always be an array.
    * @param {Object[]} results - the partial results. This argument is intended for interal use only.
    * @return {Promise} - a promise which will resolve when all pages have been fetched
    * @deprecated This will be folded into {@link Requestable#_request} in the 2.0 release.
    */
   _requestAllPages(path, options, cb, results) {
      results = results || [];

      return this._request('GET', path, options)
         .then((response) => {
            let thisGroup;
            if (response.data instanceof Array) {
               thisGroup = response.data;
            } else if (response.data.items instanceof Array) {
               thisGroup = response.data.items;
            } else {
               let message = `cannot figure out how to append ${response.data} to the result set`;
               throw new ResponseError(message, path, response);
            }
            results.push(...thisGroup);

            const nextUrl = getNextPage(response.headers.link);
            if (nextUrl && typeof options.page !== 'number') {
               log(`getting next page: ${nextUrl}`);
               return this._requestAllPages(nextUrl, options, cb, results);
            }

            if (cb) {
               cb(null, results, response);
            }

            response.data = results;
            return response;
         }).catch(callbackErrorOrThrow(cb, path));
   }
}

module.exports = Requestable;

// ////////////////////////// //
//  Private helper functions  //
// ////////////////////////// //
const METHODS_WITH_NO_BODY = ['GET', 'HEAD', 'DELETE'];
function methodHasNoBody(method) {
   return METHODS_WITH_NO_BODY.indexOf(method) !== -1;
}

function getNextPage(linksHeader = '') {
   const links = linksHeader.split(/\s*,\s*/); // splits and strips the urls
   return links.reduce(function(nextUrl, link) {
      if (link.search(/rel="next"/) !== -1) {
         return (link.match(/<(.*)>/) || [])[1];
      }

      return nextUrl;
   }, undefined);
}

function callbackErrorOrThrow(cb, path) {
   return function handler(object) {
      let error;
      if (object.hasOwnProperty('config')) {
         const {response: {status, statusText}, config: {method, url}} = object;
         let message = (`${status} error making request ${method} ${url}: "${statusText}"`);
         error = new ResponseError(message, path, object);
         log(`${message} ${JSON.stringify(object.data)}`);
      } else {
         error = object;
      }
      if (cb) {
         log('going to error callback');
         cb(error);
      } else {
         log('throwing error');
         throw error;
      }
   };
}