Home Reference Source

src/core/http.js

'use strict'

/**
 * Http Error
 */
export class HttpError {

  /**
   * @contructor
   * @param req  request
   * @param resOrErr response or Error
   */
  constructor(req, resOrErr) {
    this.req = req;

    if (typeof resOrErr === 'string') {
      this.res = null;

      this.isTimeout = (resOrErr === 'timeout');
      this.isAborted = (resOrErr === 'abort');
      this.isNetworkError = (resOrErr === 'error');
    } else {
      this.res = resOrErr;

      let code = this.res.status;
      this.isBadRequest   = (code === 400);
      this.isUnauthorized = (code === 401);
      this.isForbidden    = (code === 403);
      this.isNotFound     = (code === 404);
      this.isConflict     = (code === 409);
      this.isServerError  = (code >= 500);
    }
  }
}

/**
 * HTTP Client
 *
 * @class HttpClient
 */
export class HttpClient {

  /**
   * Constructor.
   * @param {object} [opts] - options
   * @param {string} [opts.baseURL] - base URL (default empty).
   * @param {object} [opts.headers] - custom headers
   * @param {string} [opts.bodyType] - post/put type `form` | `json`
   */
  constructor(opts = {}) {
    this.baseURL = opts.baseURL || '';
    this.headers = opts.headers || {};

    const bodyType = opts.bodyType || '';
    if (bodyType.includes('form')) {
      this.contentType = 'application/x-www-form-urlencoded';
    } else {
      this.contentType = 'application/json;charset=UTF-8';
    }
  }

  /**
   * hook async function called before the request.
   * 
   * @param  {String} path - request path
   * @param  {object} [ctx] - context
   * @param  {object} [ctx.query] - request query data
   * @param  {object} [ctx.body] - request body
   * @param  {object} [ctx.headers] - header name and value object
   */
  async beforeRequest(path, ctx) {
  }

  /**
   * hook async function called before throw an error. 
   * 
   * @param  {HttpError} err - request path
   * @return {Promise<boolean>} - if return false, stop throwing the error.
   */
  async beforeError(err) {
    return true
  }

  /**
   * do GET request.
   * 
   * @param  {String} path - request path
   * @param  {object} [ctx] - context
   * @param  {object} [ctx.query] - request query data
   * @param  {object} [ctx.body] - request body
   * @param  {object} [ctx.headers] - header name and value object
   * @return {Promise} Promise resolves response bodystatus: xhr.status,
   * @type {number} status - status code
   * @type {object} headers - response header name and value object
   * @type {*} body - response body
   */
  get(path, ctx = {}) {
    const { query, body, headers } = ctx;
    return this.exec('GET', path, query, body, headers);
  }

  /**
   * do POST request.
   * 
   * @param  {String} path - request path
   * @param  {object} [ctx] - context
   * @param  {object} [ctx.query] - request query data
   * @param  {object} [ctx.body] - request body
   * @param  {object} [ctx.headers] - header name and value object
   * @return {Promise} Promise resolves response bodystatus: xhr.status,
   * @type {number} status - status code
   * @type {object} headers - response header name and value object
   * @type {*} body - response body
   */
  post(path, ctx = {}) {
    const { query, body, headers } = ctx;
    return this.exec('POST', path, query, body, headers);
  }

  /**
   * do PUT request.
   * 
   * @param  {String} path - request path
   * @param  {object} [ctx] - context
   * @param  {object} [ctx.query] - request query data
   * @param  {object} [ctx.body] - request body
   * @param  {object} [ctx.headers] - header name and value object
   * @return {Promise} Promise resolves response bodystatus: xhr.status,
   * @type {number} status - status code
   * @type {object} headers - response header name and value object
   * @type {*} body - response body
   */
  put(path, ctx = {}) {
    const { query, body, headers } = ctx;
    return this.exec('PUT', path, query, body, headers);
  }

  /**
   * do PATCH request.
   * 
   * @param  {String} path - request path
   * @param  {object} [ctx] - context
   * @param  {object} [ctx.query] - request query data
   * @param  {object} [ctx.body] - request body
   * @param  {object} [ctx.headers] - header name and value object
   * @return {Promise} Promise resolves response bodystatus: xhr.status,
   * @type {number} status - status code
   * @type {object} headers - response header name and value object
   * @type {*} body - response body
   */
  patch(path, ctx = {}) {
    const { query, body, headers } = ctx;
    return this.exec('PATCH', path, query, body, headers);
  }

  /**
   * do DELETE request.
   * 
   * @param  {String} path - request path
   * @param  {object} [ctx] - context
   * @param  {object} [ctx.query] - request query data
   * @param  {object} [ctx.body] - request body
   * @param  {object} [ctx.headers] - header name and value object
   * @return {Promise} Promise resolves response bodystatus: xhr.status,
   * @type {number} status - status code
   * @type {object} headers - response header name and value object
   * @type {*} body - response body
   */
  delete(path, ctx = {}) {
    const { query, body, headers } = ctx;
    return this.exec('DELETE', path, query, body, headers);
  }

  /**
   * execute http request.
   * 
   * @param  {string} method - method
   * @param  {string} path - request path
   * @param  {object} query - request query data
   * @param  {string|object} body - request body
   * @param  {object} headers - header name and value object
   * @return {Promise} Promise
   * @type {number} status - status code
   * @type {object} headers - response header name and value object
   * @type {*} body - response body
   */
  async exec(method, path, query, body, headers = {}) {
    headers = { ...this.headers, ...headers }

    if (!('Content-Type' in headers)) {
      headers['Content-Type'] = this.contentType;
    }

    await this.beforeRequest(path, { query, body, headers })

    const req = { method, path, headers };
    let httpErr;
    try {
      const res = await this._request(method, path, { query, body, headers });
      const { status } = res;
      if (status < 400) {
        return res;
      }

      httpErr = new HttpError(req, res);
    } catch(errType) {
      httpErr = new HttpError(req, errType);
    }

    if (await this.beforeError(httpErr) !== false) {
      throw httpErr;
    }
  }

  _request(method, path, { query, body, headers }) {
    let reqBody;
    if (body) {
      const cttType = headers['Content-Type'];
      if (cttType.match(/\/form-data/)) {
        reqBody = new FormData();
        for (let field in body) {
          reqBody.append(field, body[field]);
        }
        delete headers['Content-Type']; // FormData set it with boundary
      } else if (cttType.match(/\/json/)) {
        reqBody = JSON.stringify(body);
      } else {
        reqBody = this._formatParams(body);
      }
    } else {
      reqBody = undefined;
    }

    let url = path;
    if (this.baseURL && !url.match(/^[a-z]{2,5}:\/\//)) {
      url = this.baseURL + url;
    }
    if (query) url += '?' + this._formatParams(query);

    return new Promise((resolve, reject) => {
        let xhr = new XMLHttpRequest();
        xhr.open(method, url, true);

        for (let name in headers) {
          xhr.setRequestHeader(name, headers[name]);
        }

        xhr.onload = (evt) => {
          let resBody, resCttType = xhr.getResponseHeader('Content-Type');
          if (resCttType === null) {
            resBody = null;
          } else if (resCttType.match(/\/json/)) {
            resBody = JSON.parse(xhr.response);
          } else if (resCttType.match(/\/form/)) {
            resBody = this._fromFormData(xhr.response);
          } else {
            resBody = xhr.response;
          }

          resolve({
            status: xhr.status,
            headers: xhr.getAllResponseHeaders(),
            body: resBody
          });
        }

        xhr.onabort = function(err){ reject('abort') };
        xhr.onerror = function(err){ reject('error') };
        xhr.ontimeout = function(err){ reject('timeout') };

        xhr.send(reqBody);
      });
  }

  _formatParams(params) {
    return new URLSearchParams(params).toString()
  }

  _fromFormData(body) {
    const data = {};
    body.split('&').forEach(item => {
      const [key, val] = item.split('=');
      data[decodeURIComponent(key)] = decodeURIComponent(val);
    });
    return data;
  }
}