requester.js

/**
 * @file Provide the requester class.
 * @author Harry Henry Gebel <hhgebel@gmail.com>
 * @copyright 2020 Harry Henry Gebel
 * @license MIT
 * @version 0.0.3
 * @module easier-requests
 * @since 0.0.0
 */
'use strict';

import axios from 'axios';
import {IDInUseError,
        RequestNotCompleteError,
        InvalidRequestError,
        UnbalancedParametersError} from './errors';

/**
 * Class representing actions to perform HTTP requests and cache their
 * responses for later retrieval.
 * @since 0.0.0
 */
class Requester {
  // TODO implement general request function

  /**
   * Create a Requester
   * @since 0.0.0
   */
  constructor() {
    // holds responses awaiting retrieval
    this.cachedResponses = {};
    this.cachedErrors = {};

    // in flight request IDs
    this.inFlightRequests = {};

    // Updated every time a unique ID is generated, in order to help
    // ensure generated ids are in fact unique.
    this.idSerialNumber = 0;

    this._defaultOptions = {
       // should we throw an error when a response fails?
      throwOnFailure: false
    };

    // copy in order to preserve original
    this._options = {...this._defaultOptions};
  }

  /**
   * Generates a unique ID to prevent ID name clashes. This function
   * will never generate the same ID twice during the existence of a
   * specific instance of the Requester class.
   * @since 0.0.1
   * @param {string} [prefix = ''] - String which will be applied as the
   * first part of every generated unique ID.
   * @return {string} The generated ID. Returned IDs have the
   * format "${prefix}#${serial number}#${timestamp}", where prefix is
   * a string prefix optionally provided by the user.
   */
  createUniqueID(prefix = '') {
    this.idSerialNumber++;

    return `${prefix}#${this.idSerialNumber}#${Date.now()}`;
  }

  /**
   * Wrap variable parameters into a params object
   * @private
   * @since 0.0.3
   * @param {string[]} params - Parameters of request. Each request
   * parameter should use two function parameters, the first the name
   * of the parameter and the second it's value. The number of
   * arguments in params should always be even.
   * @return {Object} Parameters wrapped into a params object suitable for passing to Requester._request.
   */
  _wrapParams(params) {
    // handle request parameters
    if (params.length % 2 != 0)
      throw new UnbalancedParametersError(
        "Each request parameter must have a parameter name and a value.");

    const parameters = {}; // gather params into object
    for (let i = 0; i < params.length; i += 2)
      parameters[params[i]] = params[i + 1];

    return parameters;
  }

  /**
   * Perform an HTTP DELETE request and cache response
   * @async
   * @since 0.0.3
   * @param {string} url - URL of request
   * @param {string} id - Unique ID of request, used to retrieve results
   * @param {...string} params - Parameters of request. Each request
   * parameter should use two function parameters, the first the name
   * of the parameter and the second it's value. The number of
   * arguments in params should always be even.
   */
  async delete(url, id, ...params) {
    await this._request('delete',
                        url,
                        id,
                        undefined,
                        this._wrapParams(params));
  }

  /**
   * Perform an HTTP GET request and cache response
   * @async
   * @since 0.0.1
   * @param {string} url - URL of request
   * @param {string} id - Unique ID of request, used to retrieve results
   * @param {...string} params - Parameters of request. Each request
   * parameter should use two function parameters, the first the name
   * of the parameter and the second it's value. The number of
   * arguments in params should always be even.
   */
  async get(url, id, ...params) {
    await this._request('get', url, id, undefined, this._wrapParams(params));
  }

  /**
   * Perform an HTTP PATCH request and cache response
   * @async
   * @since 0.0.3
   * @param {string} url - URL of request
   * @param {string} id - Unique ID of request, used to retrieve results
   * @param {Object} data - Data for request.
   * @param {...string} params - Parameters of request. Each request
   * parameter should use two function parameters, the first the name
   * of the parameter and the second it's value. The number of
   * arguments in params should always be even.
   */
  async patch(url, id, data, ...params) {
    await this._request('patch', url, id, data, this._wrapParams(params));
  }

  /**
   * Perform an HTTP POST request and cache response
   * @async
   * @since 0.0.3
   * @param {string} url - URL of request
   * @param {string} id - Unique ID of request, used to retrieve results
   * @param {Object} data - Data for request.
   * @param {...string} params - Parameters of request. Each request
   * parameter should use two function parameters, the first the name
   * of the parameter and the second it's value. The number of
   * arguments in params should always be even.
   */
  async post(url, id, data, ...params) {
    await this._request('post', url, id, data, this._wrapParams(params));
  }

  /**
   * Perform an HTTP PUSH request and cache response
   * @async
   * @since 0.0.3
   * @param {string} url - URL of request
   * @param {string} id - Unique ID of request, used to retrieve results
   * @param {Object} data - Data for request.
   * @param {...string} params - Parameters of request. Each request
   * parameter should use two function parameters, the first the name
   * of the parameter and the second it's value. The number of
   * arguments in params should always be even.
   */
  async push(url, id, data, ...params) {
    await this._request('push', url, id, data, this._wrapParams(params));
  }

  /**
   * Perform an axios request
   * @async
   * @private
   * @since 0.0.3
   * @param {string} method - HTTP method of request to performed
   * (GET, POST, etc.)
   * @param {string} url - URL of request
   * @param {string} id - Unique ID of request, used to retrieve results
   * @param {Object} data - Data for POST, PUT, and PATCH
   * requests. Ignored for GET requests.
   * @param {Object} params - Parameters of request. Each request
   * parameter should use two function parameters, the first the name
   * of the parameter and the second it's value. The number of
   * arguments in params should always be even.
   */
  async _request(method, url, id, data, params) {

    const config = {method: method,
                    params: params,
                    url: url,
                    data: data};

    // id cannot be in use
    if (id in this.cachedResponses || id in this.inFlightRequests)
      throw new IDInUseError(`ID ${id} is already in use`);

    const caller = this; // store this for use in callbacks
    // cache id with promise
    this.inFlightRequests[id] = axios.request(config)
    // on success, set error to undefined, on failure set response to
    // undefined
      .then(function (response) {
        caller.cachedResponses[id] = response;
        caller.cachedErrors[id] = undefined;
      })
      .catch(function (error) {
        caller.cachedResponses[id] = undefined;
        caller.cachedErrors[id] = error;
        // throw error if set in options
        if (caller._options.throwOnFailure) {
          // control flow will never reach end of function if thrown
          delete caller.inFlightRequests[id];
          throw error;
        }
      });
    await this.inFlightRequests[id];

    // get rid of cached ID since we are no longer in flight
    delete this.inFlightRequests[id];
  }

  /**
   * Check if an ID is invalid or in-flight and throw any appropriate
   * errors.
   * @private
   * @since 0.0.1
   * @param {string} id - Id to check for errors
   * @throws {RequestNotCompleteError} Thrown when a response is requested
   * from an in-flight request.
   * @throws {InvalidRequestError} Thrown when an ID does not exist. Caused
   * by a request never having been made or already having been
   * retrieved. Retrieved requests are deleted from the cache
   * to prevent a memory leak on long running apps.
   */
  _responseErrorChecker(id) {
    if (id in this.inFlightRequests)
      throw new RequestNotCompleteError(
        `Request with ID ${id} has not completed.`);
    if (!(id in this.cachedResponses) && !(id in this.inFlightRequests))
      throw new InvalidRequestError(
        `Request with ID ${id}` +
          ' has already been retrieved or was never created.');
  }

  /**
   * Retrieve an error based on it's ID
   * @since 0.0.1
   * @param {string} id - The ID passed into the HTTP request when it
   * was created
   * @return {Object} The error returned. Will be set to undefined
   * if the request succeeded.
   * @throws {RequestNotCompleteError} Thrown when a response is requested
   * from an in-flight request.
   * @throws {InvalidRequestError} Thrown when an ID does not exist. Caused
   * by a request never having been made or already having been
   * retrieved. Retrieved requests are deleted from the cache
   * to prevent a memory leak on long running apps.
   */
  error(id) {
    // check for errors
    this._responseErrorChecker(id);

    if (this.cachedErrors[id] === undefined)
      // do not delete until the response is retrieved
      return undefined;

    const errorReturned = this.cachedErrors[id];
    delete this.cachedErrors[id];
    delete this.cachedResponses[id];
    return errorReturned;
  }

  /**
   * Retrieve a response based on it's ID
   * @since 0.0.0
   * @param {string} id - The ID passed into the HTTP request when it
   * was created
   * @return {Object} The response returned. Will be set to undefined
   * if the request failed.
   * @throws {RequestNotCompleteError} Thrown when a response is requested
   * from an in-flight request.
   * @throws {InvalidRequestError} Thrown when an ID does not exist. Caused
   * by a request never having been made or already having been
   * retrieved. Retrieved requests are deleted from the cache
   * to prevent a memory leak on long running apps.
   */
  response(id) {
    // check for errors
    this._responseErrorChecker(id);


    if (this.cachedResponses[id] === undefined)
      // do not delete until the error is retrieved
      return undefined;

    const response = this.cachedResponses[id];
    delete this.cachedErrors[id];
    delete this.cachedResponses[id];
    return response;
  }

  /** Set new options, or restore to defaults.
  * @since 0.0.3
  * @param {Object} options - Options passed in to function. Options
  * not set in options will be left at current value. If options is an
  * empty object, reset all options to defaults. If options is null,
  * return a copy of the current options.
  * @return {Object} Copy of options after any changes.
  */
  setOptions(options) {
    // test for empty object
    if (Object.keys(options).length === 0)
      this._options = this._defaultOptions;
    else if (options !== null)
      this._options = Object.assign(this._options, options);

    return {...this._options};
  }
}

/**
 * Preconstructed Requester() instance. Requester is designed to be used primarily from this exported instance.
 */
const requester = new Requester();
export default requester;

//  LocalWords:  RequestNotCompleteError InvalidRequestError IDInUseError
//  LocalWords:  idSerialNumber params