Rest.js

import md5 from 'blueimp-md5';
import superagent from 'superagent';
import xmldom from '@xmldom/xmldom';
import { isBrowser } from './utils.js';
import jsonp from 'superagent-jsonp';
import RateLimit from "./RateLimit.js";

/**
 * Check if requests will be to the same domain, i.e. no CORS.
 * Must be used in a browser environment.
 *
 * @param url
 * @returns {boolean}
 */
const sameOrigin = (url) => {
  const a1 = document.createElement('a');
  const a2 = document.createElement('a');
  a1.href = url;
  a2.href = window.location.href;

  return a1.hostname === a2.hostname
    && a1.port === a2.port
    && a1.protocol === a2.protocol
    && a2.protocol !== 'file:';
};

/**
 * Last 7 digits of milliseconds since 1970 + random number.
 * @return {number}
 */
const getPreventCacheNumber = () => new Date().getTime()%10000000*1000+Math.floor((Math.random() * 1000).toString());

/**
 * This class encapsulates functionality for communicating with the repository via Ajax calls.
 * Authentication is done via cookies and accept headers are in general set to
 * application/json behind the scenes.
 *
 * @exports store/Rest
 */
export default class Rest {
  constructor() {
    this.timeout = 30000; // 30 seconds
    this.JSONP = true;
    this.withCredentials = true;
    this.headers = {
      Accept: 'application/json',
      'Content-Type': 'application/json; charset=UTF-8',
    };

    const rest = this;

    if (isBrowser()) {
      /**
       *
       * @param uri
       * @param {Object} data
       * @param format
       * @return {undefined|*}
       */
      rest._putFile = (uri, data, format = 'application/json') => {
        if (!data.value) {
          return undefined;
        }

        const stubForm = new FormData();
        const { files } = data;

        Array.from(files).forEach((file, idx) => {
          // is the item a File?
          if (file instanceof File) {
            stubForm.append(idx.toString(), file);
          }
        });

        const POSTRequest = superagent.post(uri)
          .accept(format)
          .send(stubForm);

        if (this.withCredentials) {
          POSTRequest.withCredentials();
        }

        return POSTRequest;
      };
    }
  }

  /**
   * Set up the rate limitation for read requests.
   * The only required method of the instance is the 'enqueue' function, see it's signature in the default
   * RateLimitation class.
   * @param {Object} rateLimtationInstance
   * @see RateLimit#enqueue
   */
  setRateLimitationForRead(rateLimtationInstance) {
    this.readRateLimit = rateLimtationInstance;
  }

  /**
   * Set up the rate limitation for write requests.
   * The only required method of the instance is the 'enqueue' function, see it's signature in the default
   * RateLimitation class.
   * @param {Object} rateLimtationInstance
   * @see RateLimit#enqueue
   */
  setRateLimitationForWrite(rateLimtationInstance) {
    this.writeRateLimit = rateLimtationInstance;
  }

  /**
   * Disable JSONP for all requests, e.g. when there is a need for performance and there
   * is a need for relable caching which does not work with JSONP.
   */
  disableJSONP() {
    this.JSONP = false;
  }

  /**
   * Enable JSONP for all get requests. JSONP will only be used if EntryStore.js is running in the browser and
   * there are cross-site GET requests.
   * Note that JSONP is enabled in this scenario by default.
   */
  enableJSONP() {
    this.JSONP = true;
  }

  /**
   * Don't allow credentials, i.e. don't send cookies when doing requests.
   */
  disableCredentials() {
    this.withCredentials = false;
  }

  /**
   * Allow credentials, i.e. sending cookies, when doing requests.
   */
  enableCredentials() {
    this.withCredentials = true;
  }

  /**
   * @param {object} credentials should contain attributes "user", "password", and "maxAge".
   * MaxAge is the amount of seconds the authorization should be valid.
   * @return {Promise} A thenable object
   * @async
   */
  async auth(credentials) {
    const { user, password, base, maxAge = 604800, logout = false } = credentials;
    delete this.headers.cookie;

    if (logout) {
      const logoutRequestResult = superagent.get(`${base}auth/logout`)
        .accept('application/json')
        .withCredentials()
        .timeout({ response: this.timeout });

      Object.entries(this.headers).map(keyVal => logoutRequestResult.set(keyVal[0], keyVal[1]));

      return logoutRequestResult;
    }

    const data = {
      auth_username: encodeURIComponent(user),
      auth_password: encodeURIComponent(password),
      auth_maxage: maxAge,
    };

    if (isBrowser()) {
      return this.post(`${base}auth/cookie`, data, null, 'application/x-www-form-urlencoded');
    }
    const queryStringData = Object.entries(data).reduce((accum, prop) => `${accum}${prop.join('=')}&`, '');
    const authCookieResponse = await this.post(`${base}auth/cookie`, queryStringData, null, 'application/x-www-form-urlencoded');
    const cookies = authCookieResponse.headers['set-cookie'];

    for (const cookie of cookies) {
      if (cookie.startsWith('auth_token=')) {
        this.headers.cookie = [cookie];
        break;
      }
    }

    return authCookieResponse;
  }

  /**
   * Fetches data from the provided URI.
   * If a cross-domain call is made and we are in a browser environment a jsonp call is made.
   *
   * @param {string} uri - URI to a resource to fetch.
   * @param {string|null} format - the format to request as a mimetype.
   * @param {boolean} nonJSONP - stop JSONP handling (default false)
   * @param {stream} writableStream - a writable stream to be used in nodejs e.g. for piping data directly to a file
   * @param {boolean} preventCache - if true an extra argument is added to the uri with a random number to prevent caching
   * @return {Promise} A thenable object
   * @async
   * @throws Error
   */
  async get(uri, format = null, nonJSONP = false, writableStream, preventCache = false) {
    if (this.readRateLimit) {
      return this.readRateLimit.enqueue(this._get, this, [uri, format, nonJSONP, writableStream, preventCache]);
    }
    return this._get(uri, format, nonJSONP, writableStream, preventCache);
  }

  /**
   * @private
   * @see get
   */
  async _get(uri, format, nonJSONP, writableStream, preventCache) {
    let _format = format;
    const locHeaders = Object.assign({}, this.headers);
    locHeaders['X-Requested-With'] = null;
    delete locHeaders['Content-Type'];

    let _uri = uri;
    let handleAs = 'json';
    if (_format != null) {
      switch (_format) {
        case 'application/json': // This is the default in the headers.
          break;
        case 'xml': // backward compatible case
        case 'application/xml':
        case 'text/xml':
          _format = 'text/xml';
          handleAs = 'xml';
          break;
        case 'text': // backward compatible case
        case 'text/plain':
          _format = 'text/plain';
          handleAs = 'text';
          break;
        default: // All other situations, including text/plain.
          handleAs = 'text';
      }
      locHeaders.Accept = _format;
    }

    // Use jsonp instead of CORS for GET requests when doing cross-domain calls, it is cheaper
    if (isBrowser() && !sameOrigin(_uri) && !nonJSONP && this.JSONP) {
      return new Promise((resolve, reject) => {
        const queryParameter = new RegExp('[?&]format=');
        if (!queryParameter.test(_uri)) {
          _uri += `${_uri.includes('?') ? '&' : '?'}format=application/json`;
        }

        superagent.get(_uri)
          .use(
            jsonp({
              timeout: 1000000,
              // @scazan: superagent-jsonp's random number generator is weak, so we create our own
              callbackName: `cb${md5(_uri).slice(0, 7)}${getPreventCacheNumber()}`,
            }),
          ) // Need this timeout to prevent a superagentCallback*** not defined issue with superagent-jsonp: https://github.com/lamp/superagent-jsonp/issues/31
          .then((data) => {
            resolve(data.body);
          }, reject);
      });
    }
    const GETRequest = superagent.get(_uri)
      .timeout({
        response: this.timeout,
      });
    if (preventCache) {
      GETRequest.query({ preventCache: getPreventCacheNumber() });
    }
    if (this.withCredentials) {
      GETRequest.withCredentials();
    }

    if (handleAs === 'xml') {
      GETRequest.parse['text/xml'] = (res, callback) => {
        const DOMParser = isBrowser() ? window.DOMParser : xmldom.DOMParser;
        const parser = new DOMParser();

        if (isBrowser()) {
          return parser.parseFromString(res, 'application/xml');
        }

        // Node handles the return as a callback
        res.text = parser.parseFromString(res.text, 'application/xml');
        callback(null, res);

        return res.text;
      };
    }

    Object.entries(locHeaders).map(keyVal => GETRequest.set(keyVal[0], keyVal[1]));

    if (writableStream) {
      return new Promise((succ, err) => {
        GETRequest.pipe(writableStream);
        GETRequest.on('end', (error) => {
          if (error) {
            err(error);
          } else {
            succ();
          }
        });
      });
    }

    const response = await GETRequest;
    if (response.statusCode === 200) {
      if (handleAs === 'text' || handleAs === 'xml') {
        // eslint-disable-next-line no-nested-ternary
        return response.text !== undefined ? response.text
          : (response.body instanceof Buffer ? response.toString() : undefined);
      }
      return response.body;
    }
    throw new Error(`Resource could not be loaded: ${response.text}`);
  }

  /**
   * Posts data to the provided URI.
   *
   * @param {String} uri - an URI to post to.
   * @param {String|Object} data - the data to post. If an object the data is sent as form data.
   * @param {Date=} modDate a date to use for the HTTP if-unmodified-since header.
   * @param {string=} format - indicates the content-type of the data, default is
   * application/json, except if the data is an object in which case the default is
   * multipart/form-data.
   * @return {Promise} A thenable object
   */
  post(uri, data, modDate, format) {
    if (this.writeRateLimit) {
      return this.writeRateLimit.enqueue(this._post, this, [uri, data, modDate, format]);
    }
    return this._post(uri, data, modDate, format);
  }

  /**
   * @private
   * @see post
   */
  _post(uri, data, modDate, format) {
    const locHeaders = Object.assign({}, this.headers);
    if (modDate) {
      locHeaders['If-Unmodified-Since'] = modDate.toUTCString();
    }// multipart/form-data
    if (format) {
      locHeaders['Content-Type'] = format;
    }

    const POSTRequest = superagent.post(uri);

    if (this.withCredentials) {
      POSTRequest.withCredentials();
    }

    if (data) {
      POSTRequest.send(data)
      // serialize the object into a format that the backend is used to (no JSON strings)
        .serialize(obj => Object.entries(obj)
          .map(keyVal => `${keyVal[0]}=${keyVal[1]}&`)
          .join(''));
    }

    POSTRequest.timeout({ response: this.timeout });

    Object.entries(locHeaders).map(keyVal => POSTRequest.set(keyVal[0], keyVal[1]));

    return POSTRequest;
  }

  /**
   * Posts data to a factory resource with the intent to create a new resource.
   * That is, it posts data and expects a Location header back with information on the created
   * resource.
   *
   * @param {string} uri - factory resource, may include parameters.
   * @param {string|Object} data - the data that is to be posted as a string,
   * if an object is provided it will be serialized as json.
   * @returns {Promise.<String>}
   */
  async create(uri, data) {
    const response = await this.post(uri, data);
    // let location = response.getHeader('Location');
    let { location } = response.headers;
    // In some weird cases, like when making requests from file:///
    // we do not have access to headers.
    if (!location && response.body) {
      const idx = uri.indexOf('?');
      if (idx !== -1) {
        location = uri.substring(0, uri.indexOf('?'));
      } else {
        location = uri;
      }
      location += `/entry/${JSON.parse(response.body).entryId}`;
    }

    return location;
  }

  /**
   * Replaces a resource with a new representation.
   *
   * @param {string} uri the address to put to.
   * @param {string|Object} data - the data to put. If an object the data is sent as form data.
   * @param {Date=} modDate a date to use for the HTTP if-unmodified-since header.
   * @param {string=} format - indicates the content-type of the data, default is
   * application/json, except if the data is an object in which case the default is
   * multipart/form-data.
   * @return {Promise} A thenable object
   */
  put(uri, data, modDate, format) {
    if (this.writeRateLimit) {
      return this.writeRateLimit.enqueue(this._put, this, [uri, data, modDate, format]);
    }
    return this._put(uri, data, modDate, format);
  }

  /**
   * @private
   * @see put
   */
  _put(uri, data, modDate, format) {
    const locHeaders = Object.assign({}, this.headers);
    if (modDate) {
      locHeaders['If-Unmodified-Since'] = modDate.toUTCString();
    }
    if (format) {
      locHeaders['Content-Type'] = format;
    } else if (typeof data === 'object') {
      locHeaders['Content-Type'] = 'application/json'; // @todo perhaps not needed, this is default
    }

    const PUTRequest = superagent.put(uri)
      .send(data)
      .timeout({ response: this.timeout });

    if (this.withCredentials) {
      PUTRequest.withCredentials();
    }

    Object.entries(locHeaders).map(keyVal => PUTRequest.set(keyVal[0], keyVal[1]));

    return PUTRequest;
  }

  /**
   * Deletes a resource.
   *
   * @param {String} uri of the resource that is to be deleted.
   * @param {Date=} modDate a date to use for the HTTP if-unmodified-since header.
   * @return {Promise} A thenable object
   */
  del(uri, modDate) {
    if (this.writeRateLimit) {
      return this.writeRateLimit.enqueue(this._del, this, [uri, modDate]);
    }
    return this._del(uri, modDate);
  }

  /**
   * @private
   * @see del
   */
  _del(uri, modDate) {
    const locHeaders = Object.assign({}, this.headers);
    delete locHeaders['Content-Type'];
    if (modDate) {
      locHeaders['If-Unmodified-Since'] = modDate.toUTCString();
    }

    const DELETERequest = superagent.del(uri)
      .withCredentials()
      .timeout({ response: this.timeout });

    if (this.withCredentials) {
      DELETERequest.withCredentials();
    }

    Object.entries(locHeaders).map(keyVal => DELETERequest.set(keyVal[0], keyVal[1]));

    return DELETERequest;
  }

  /**
   * Put a file to a URI.
   * In a browser environment a file is represented via an input tag which references
   * the file to be uploaded via its value attribute.
   * In node environments the file is represented as a stream constructed via
   * fs.createReadStream('file.txt').
   *
   * _**Under the hood** the tag is moved into a form in an invisible iframe
   * which then is submitted. If there is a response it is provided in a textarea which
   * can be looked into since we are on the same domain._
   *
   * @param {string} uri the URI to which we will put the file.
   * @param {data} data - input tag or stream that may for instance correspond to a file
   * in a nodejs setting.
   * @param {string} format the format to handle the response as, either text, xml, html or json
   * (json is default).
   * @return {Promise} A thenable object
   */
  putFile(uri, data, format) {
    if (this.writeRateLimit) {
      return this.writeRateLimit.enqueue(this._putFile, this, [uri, data, format]);
    }
    return this._putFile(uri, data, format);
  }

  /**
   * @private
   * @see putFile
   */
  _putFile(uri, data, format) {
    if (data && data.constructor && data.constructor.pipeline) {
      return new Promise((resolve, reject) => {
        const upload = superagent.put(uri)
          .timeout({ response: this.timeout });
        const locHeaders = Object.assign({}, this.headers);
        if (format) {
          locHeaders['Content-Type'] = format;
        }
        Object.entries(locHeaders).map(keyVal => upload.set(keyVal[0], keyVal[1]));

        const req = upload.request();
        data.constructor.pipeline(data, req, (err) => {
          if (err) {
            upload.abort();
            return reject(err);
          }

          upload.end((err2, res) => {
            if (err2) return reject(err2);
            return resolve(res);
          });
        });
      });
    }
    return Promise.reject(new Error('Data parameter must be a readable stream'));
  }
}