Source: Request.js

'use strict';

/**
 * @module openveo-rest-nodejs-client/Request
 */

const timers = require('timers');
const FormData = require('form-data');

class Request {

  /**
   * Creates a REST request which can be executed / aborted.
   *
   * @class Request
   * @constructor
   * @param {String} protocol The protocol to use for the request (either 'http' or 'https')
   * @param {Object} [options] The complete list of http(s) options as described by NodeJS http.request
   * documentation. More headers can be added when executing the request.
   * @param {(String|Object)} [body] The request body
   * @param {Number} [timeout=10000] Maximum execution time for the request (in ms), set it to Infinity for a request
   * without limits
   * @param {Boolean} [multiparted=false] true to send body as multipart/form-data
   * @throws {TypeError} Thrown if protocol is not valid or options is not a valid object
   */
  constructor(protocol, options, body, timeout, multiparted) {
    if (!options || (options && typeof options !== 'object'))
      throw new TypeError('Invalid request options');

    if (!protocol || typeof protocol !== 'string')
      throw new TypeError('Invalid protocol');

    if (body && typeof body !== 'string' && !multiparted)
      body = JSON.stringify(body);

    Object.defineProperties(this,

      /** @lends module:openveo-rest-nodejs-client/Request~Request */
      {

        /**
         * Request protocol either "http" or "https".
         *
         * @type {String}
         * @instance
         * @readonly
         */
        protocol: {value: protocol},

        /**
         * Request options.
         *
         * @type {Object}
         * @instance
         * @readonly
         */
        options: {value: options},

        /**
         * The request body.
         *
         * @type {String}
         * @instance
         * @readonly
         */
        body: {value: body},

        /**
         * The HTTP(S) request.
         *
         * @type {Object}
         * @see {@link https://nodejs.org/dist/latest-v16.x/docs/api/http.html#http_class_http_clientrequest}
         * @instance
         */
        request: {writable: true},

        /**
         * Maximum execution time for the request (in ms).
         *
         * @type {Number}
         * @default 10000
         * @instance
         */
        executionTimeout: {value: (timeout || 10000), writable: true},

        /**
         * Maximum time to wait until the request is aborted (in ms).
         *
         * @type {Number}
         * @default 2000
         * @instance
         */
        abortTimeout: {value: 2000, writable: true},

        /**
         * Indicates if request is actually running.
         *
         * @type {Boolean}
         * @default false
         * @instance
         */
        isRunning: {value: false, writable: true},

        /**
         * The number of attempts made on this request.
         *
         * @type {Number}
         * @default 0
         * @instance
         */
        attempts: {value: 0, writable: true},

        /**
         * Indicates if request body must be sent as multipart/form-data.
         *
         * @type {Boolean}
         * @default false
         * @instance
         */
        multiparted: {value: multiparted, writable: true}

      }

    );
  }

  /**
   * Executes the request.
   *
   * Be careful, if request is executed while still running, the running one will be aborted.
   *
   * @async
   * @param {Object} [headers] A list of http(s) headers. Headers will be merged with Request headers set in
   * the constructor. It takes priority over Request headers.
   * @return {Promise} Promise resolving with request's response as an Object, all request's responses are
   * considered success, promise is rejected only if an error occured during the transfer or while parsing the
   * reponse's body (expected JSON)
   * @throws {TypeError} Thrown if options is not a valid object
   */
  execute(headers) {
    if (headers && typeof headers !== 'object')
      throw new TypeError('Invalid request options');

    let form;
    this.isRunning = true;
    this.options.headers = Object.assign(this.options.headers, headers || {});

    if (this.multiparted) {

      // Request body should be sent as multipart/form-data, use form-data module to achieve this
      form = new FormData();
      this.options.headers = form.getHeaders(this.options.headers);

    }

    return this.abort().then(() => {
      return new Promise((resolve, reject) => {
        this.isRunning = true;

        // Send request to the web service
        this.request = require(this.protocol).request(this.options, (response) => {
          let body = '';

          response.setEncoding('utf8');
          response.on('error', (error) => {
            this.isRunning = false;
            return reject(error);
          });
          response.on('data', (chunk) => body += chunk);
          response.on('end', () => {
            this.isRunning = false;

            try {
              const result = body ? JSON.parse(body) : {};
              result.httpCode = response.statusCode;
              resolve(result);
            } catch (error) {
              reject(new Error('Server error, response is not valid JSON'));
            }
          });
        });

        this.request.on('error', (error) => {
          this.isRunning = false;
          return reject(error);
        });

        if (this.executionTimeout !== Infinity) {
          this.request.setTimeout(this.executionTimeout, () => {
            this.abort().then(() => {
              reject(new Error('Server unavaible'));
            }).catch(() => {
              reject(new Error('Request can\'t be aborted'));
            });
          });
        }

        if (this.body) {
          if (!this.multiparted) {

            // Request body should be sent as is
            this.request.write(this.body);
            return this.request.end();

          }

          // Request body should be sent as multipart/form-data
          for (const fieldName in this.body) {
            form.append(fieldName, this.body[fieldName]);
          }

          form.pipe(this.request);
        } else
          this.request.end();

      });

    });
  }

  /**
   * Aborts the request.
   *
   * @async
   * @return {Promise} Promise resolving when request has been aborted, promise is rejected if it takes too long
   * to abort the request
   */
  abort() {
    return new Promise((resolve, reject) => {
      if (this.request && this.isRunning) {
        const timeoutReference = timers.setTimeout(() => {
          reject('Request couldn\'t be aborted');
        }, this.abortTimeout);

        this.request.on('abort', () => {
          this.isRunning = false;
          timers.clearTimeout(timeoutReference);
          resolve();
        });

        this.request.abort();
      } else
        resolve();
    });
  }

}

module.exports = Request;