Source: Common/Requestable.js

import 'isomorphic-form-data';
import fetch from 'isomorphic-unfetch';
import queryString from 'query-string';
import debug from 'debug';
import { APP_NAME, CONTENT_TYPES } from './Constant';
import {
  ResponseError,
  AuthorizationError,
  NotFoundError,
  BadRequestError,
  ConflictError,
  IllegalArgumentsError,
} from '../Error';

const log = debug(`${APP_NAME}:Requestable`);
/**
 * Requestable wraps the logic for making http requests to the API
 */
class Requestable {
  /**
   * Initialize the http internals.
   * @param {Requestable.auth} [auth] - the credentials to authenticate to Xnat. If auth is
   *                                  not provided request will be made unauthenticated
   * @param {string} [apiBase] - the base Xnat URL
   */
  constructor(jsXnat) {
    this.jsXnat = jsXnat;
    // this.__apiBase = cleanseUrl(apiBase);
    // this.__auth = {
    //   token: auth.token,
    //   username: auth.username,
    //   password: auth.password,
    // };

    // 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.jsXnat.basePath + path;
    }

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

  /**
   * Compute the headers required for an API request.
   * @private
   * @return {Object} - the headers to use in the request
   */
  async __getRequestHeaders(requiredAuthMethod) {
    const headers = {};

    headers.Authorization = await this.jsXnat.getAuthorizationHeader(
      requiredAuthMethod
    );

    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;
  }

  /**
   * 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 {*} [bodyParams] - 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
   */
  async _request(
    method,
    path,
    bodyParams,
    cb,
    contentType = CONTENT_TYPES.json,
    requiredAuthMethod = undefined
  ) {
    let url = this.__getURL(path);
    const headers = await this.__getRequestHeaders(requiredAuthMethod);
    let queryParams = {};
    let shouldUseDataAsQueryString = false;
    let body = undefined;

    if (bodyParams) {
      shouldUseDataAsQueryString =
        typeof bodyParams === 'object' && methodHasNoBody(method);
      if (shouldUseDataAsQueryString) {
        queryParams = bodyParams;
        bodyParams = undefined;
      }
    }
    if (contentType === CONTENT_TYPES.json) {
      headers['Content-Type'] = contentType;
      body = JSON.stringify(bodyParams);
    } else if (
      contentType === CONTENT_TYPES.binary ||
      contentType === CONTENT_TYPES.xml ||
      contentType === CONTENT_TYPES.plain
    ) {
      headers['Content-Type'] = contentType;
      body = bodyParams;
    } else if (contentType === CONTENT_TYPES.form) {
      headers['Content-Type'] = contentType;
      if (bodyParams instanceof URLSearchParams) {
        body = bodyParams;
      } else {
        const urlParams = new URLSearchParams();
        Object.keys(bodyParams).forEach((key) => {
          urlParams.append(key, bodyParams[key]);
        });
        body = urlParams;
      }
    } else if (contentType === CONTENT_TYPES.multipart) {
      // Content-Type for multipart form type will be automtically popuplated by note-fetch (along with the boundary parameter)
      if (bodyParams instanceof FormData) {
        body = bodyParams;
      } else {
        const formData = new FormData();
        Object.keys(bodyParams).forEach((key) => {
          formData.append(key, bodyParams[key]);
        });
        body = formData;
      }
    } else {
      throw new IllegalArgumentsError(
        `Content Type is not valid: ${contentType}`
      );
    }

    const config = {
      method: method,
      headers: headers,
      body,
      // body: typeof data === 'object' ? JSON.stringify(data) : data,
    };
    if (Object.keys(queryParams).length > 0) {
      url = `${url}?${queryString.stringify(queryParams)}`;
    }
    log(`${config.method} to ${url}`);
    let responseData;
    try {
      const response = await fetch(url, config);
      responseData = await getResponseData(response);
      if (!response.ok) {
        throwErrorBasedOnStatus(path, responseData, response);
      }
      if (cb) cb(null, responseData, response);
    } catch (err) {
      callbackErrorOrThrow(cb, path, err);
    }
    return responseData;
  }

  /**
   * 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;
      }
    );
  }
}

export default Requestable;

// ////////////////////////// //
//  Private helper functions  //
// ////////////////////////// //
const METHODS_WITH_NO_BODY = ['GET', 'HEAD', 'DELETE'];
/**
 * check if the http method provided has a body part
 * @param {string} method - upper-case http method name
 * @return {boolean} - has body
 */
function methodHasNoBody(method) {
  return METHODS_WITH_NO_BODY.indexOf(method) !== -1;
}

/**
 * ??
 * @param {string} linksHeader - linksHeader separated by comma
 * @return {string} - nextUrl
 */
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);
}

/**
 * Callback or throw
 * @param {function} cb - callback function
 * @param {string} path - api path
 * @return {void}
 */
function callbackErrorOrThrow(cb, path, object) {
  let error;
  if (Object.prototype.hasOwnProperty.call(object, 'config')) {
    const { status, statusText, url } = object;
    const message = `${status} error making request to ${url}: "${statusText}"`;
    error = new ResponseError(message, path, object);
    log(`${message} ${JSON.stringify(object.data)}`);
  } else {
    error = object;
  }
  if (cb) {
    log('Calling the callback function provided', cb.name);
    cb(error);
  } else {
    log('Throwing error');
    throw error;
  }
}

function getResponseContentType(response) {
  const contentType = response.headers.get('content-type');
  return contentType ? contentType.split(';')[0].trim() : undefined;
}

async function getResponseData(response) {
  const contentType = getResponseContentType(response);
  log('response content-type', contentType);
  let responseData = undefined;
  if (contentType === 'application/json') {
    responseData = await response.json();
  } else {
    responseData = await response.text();
  }
  return responseData;
}

function throwErrorBasedOnStatus(path, responseData, response) {
  const status = response.status;

  if (status.ok) {
    return;
  } else if (status === 400) {
    throw new BadRequestError(`Bad Request`, path, responseData, response);
  } else if (status === 401 || status === 403) {
    throw new AuthorizationError(
      `Not authorized to call`,
      path,
      response,
      responseData
    );
  } else if (status === 404) {
    throw new NotFoundError(`Not found`, path, responseData, response);
  } else if (status === 409) {
    throw new ConflictError(`Conflict`, path, responseData, response);
  } else {
    throw new ResponseError(
      `Unknown error happened`,
      path,
      responseData,
      response
    );
  }
}