Source: api/http.js

'use strict';

const { Agent } = require('http');

const superagent = require('superagent');

const { DataTypes } = require('general-mq/lib/constants');

const keepAliveAgent = new Agent({ keepAlive: true });

/**
 * Options of the HTTP client `Client` that contains OAuth2 information.
 *
 * @typedef {Object} ClientOptions
 * @property {string} authBase `sylvia-iot-auth` base path with scheme. For example
 *           `http://localhost:1080/auth`.
 * @property {string} coremgrBase `sylvia-iot-coremgr` base path with scheme. For example
 *           `http://localhost:1080/coremgr`.
 * @property {string} clientId Client ID.
 * @property {string} clientSecret Client secret.
 */

/**
 * The HTTP client to request Sylvia-IoT APIs. With this client, you do not need to handle 401
 * refresh token flow.
 *
 * @class Client
 */
class Client {
  /**
   * Create an instance.
   *
   * @param {ClientOptions} opts
   * @throws {Error} Wrong arguments.
   */
  constructor(opts) {
    if (!opts || typeof opts !== DataTypes.Object || Array.isArray(opts)) {
      throw Error('`opts` is not an object');
    } else if (!opts.authBase || typeof opts.authBase !== DataTypes.String) {
      throw Error('`opts.authBase` is not a string');
    } else if (!opts.coremgrBase || typeof opts.coremgrBase !== DataTypes.String) {
      throw Error('`opts.coremgrBase` is not a string');
    } else if (!opts.clientId || typeof opts.clientId !== DataTypes.String) {
      throw Error('`opts.clientId` is not a string');
    } else if (!opts.clientSecret || typeof opts.clientSecret !== DataTypes.String) {
      throw Error('`opts.clientSecret` is not a string');
    }

    this.#authBase = opts.authBase;
    this.#coremgrBase = opts.coremgrBase;
    this.#clientId = opts.clientId;
    this.#clientSecret = opts.clientSecret;
  }

  /**
   * Execute a Sylvia-IoT API request.
   *
   * @param {string} method
   * @param {string} apiPath The relative path (of the coremgr base) the API with query string. For
   *        example: `/api/v1/user/list?contains=word`, the client will do a request with
            `http://coremgr-host/coremgr/api/v1/user/list?contains=word` URL.
   * @param {Object} [body]
   * @param {function} callback
   *   @param {?Error} callback.err
   *   @param {number} callback.status
   *   @param {Object|Array} callback.body
   * @throws {Error} Wrong arguments.
   */
  request(method, apiPath, body, callback) {
    if (!method || typeof method !== DataTypes.String) {
      throw Error('`method` is not a string');
    } else if (!apiPath || typeof apiPath !== DataTypes.String) {
      throw Error('`apiPath` is not a string');
    }
    if (typeof body === DataTypes.Function) {
      callback = body;
      body = undefined;
    }
    if (body !== undefined && (!body || typeof body !== DataTypes.Object)) {
      throw Error('`body` is not an object');
    }
    if (typeof callback !== DataTypes.Function) {
      throw Error('`callback` is not a function');
    }

    let retry = 1;
    let retStatus = 0;
    let retBody = null;
    const self = this;
    (function innerRequest() {
      if (retry < 0) {
        return void callback(null, retStatus, retBody);
      }

      if (!self.#accessToken) {
        return void self.#authToken((err, token) => {
          if (err) {
            return void callback(err);
          }
          self.#accessToken = token;
          retry--;
          innerRequest();
        });
      }

      superagent(method, `${self.#coremgrBase}${apiPath}`)
        .agent(keepAliveAgent)
        .set('Authorization', `Bearer ${self.#accessToken}`)
        .send(body)
        .buffer(false)
        .end((err, res) => {
          if (!res) {
            return void callback(err || Error(JSON.stringify({ code: 'err_rsc' })));
          }
          let body = res.body;
          if (res.body.length > 0) {
            // Try to parse JSON body.
            try {
              body = JSON.parse(res.body.toString());
            } catch (e) {}
          }

          retStatus = res.statusCode;
          retBody = body;
          if (res.statusCode === 401) {
            return void self.#authToken((err, token) => {
              if (err) {
                return void callback(err);
              }
              self.#accessToken = token;
              retry--;
              innerRequest();
            });
          }
          callback(null, retStatus, retBody);
        });
    })();
  }

  /**
   * To authorize the client and get access token/refresh token.
   *
   * @param {function} callback
   *   @param {?Error} callback.err
   *   @param {string} callback.token The access token.
   * @throws {Error} Wrong arguments.
   */
  #authToken(callback) {
    if (typeof callback !== DataTypes.Function) {
      throw Error('`callback` is not a function');
    }
    const url = `${this.#authBase}/oauth2/token`;
    const body = { grant_type: 'client_credentials' };
    superagent
      .agent(keepAliveAgent)
      .post(url)
      .auth(this.#clientId, this.#clientSecret)
      .type('form')
      .accept('application/json')
      .send(body)
      .redirects(0)
      .end((err, res) => {
        if (!res) {
          const body = {
            code: 'err_rsc',
            message: `${err}`,
          };
          return void callback(Error(JSON.stringify(body)));
        } else if (res.statusCode !== 200) {
          return void callback(Error(JSON.stringify(res.body)));
        }
        callback(null, res.body.access_token);
      });
  }

  /**
   * `sylvia-iot-auth` base path.
   *
   * @type {string}
   */
  #authBase;
  /**
   * `sylvia-iot-coremgr` base path.
   *
   * @type {string}
   */
  #coremgrBase;
  /**
   * Client ID.
   *
   * @type {string}
   */
  #clientId;
  /**
   * Client secret.
   *
   * @type {string}
   */
  #clientSecret;
  /**
   * The access token.
   *
   * @type {string}
   */
  #accessToken;
}

module.exports = {
  Client,
};