Source: index.js

/** Class to realize fetch interceptors */
class FetchInterceptor {
  constructor() {
    this.interceptors = [];

    /* global fetch */
    this.fetch = (...args) => this.interceptorWrapper(fetch, ...args);
  }

  /**
   * add new interceptors
   * @param {(Object|Object[])} interceptors
   */
  addInterceptors(interceptors) {
    const removeIndex = [];

    if (Array.isArray(interceptors)) {
      interceptors.map((interceptor) => {
        removeIndex.push(this.interceptors.length);
        return this.interceptors.push(interceptor);
      });
    } else if (interceptors instanceof Object) {
      removeIndex.push(this.interceptors.length);
      this.interceptors.push(interceptors);
    }

    this.updateInterceptors();

    return () => this.removeInterceptors(removeIndex);
  }

  /**
   * remove interceptors by indexes
   * @param {number[]} indexes
   */
  removeInterceptors(indexes) {
    if (Array.isArray(indexes)) {
      indexes.map(index => this.interceptors.splice(index, 1));
      this.updateInterceptors();
    }
  }

  /**
   * @private
   */
  updateInterceptors() {
    this.reversedInterceptors = this.interceptors
      .reduce((array, interceptor) => [interceptor].concat(array), []);
  }

  /**
   * remove all interceptors
   */
  clearInterceptors() {
    this.interceptors = [];

    this.updateInterceptors();
  }

  /**
   * @private
   */
  interceptorWrapper(fetch, ...args) {
    let promise = Promise.resolve(args);

    this.reversedInterceptors.forEach(({ request, requestError }) => {
      if (request || requestError) {
        promise = promise.then(() => request(...args), requestError);
      }
    });

    promise = promise.then(() => fetch(...args));

    this.reversedInterceptors.forEach(({ response, responseError }) => {
      if (response || responseError) {
        promise = promise.then(response, responseError);
      }
    });

    return promise;
  }
}

/**
 * GraphQL client with fetch api.
 * @extends FetchInterceptor
 */
class FetchQL extends FetchInterceptor {
  /**
   * Create a FetchQL instance.
   * @param {Object} options
   * @param {String} options.url - the server address of GraphQL
   * @param {(Object|Object[])=} options.interceptors
   * @param {{}=} options.headers - request headers
   * @param {FetchQL~requestQueueChanged=} options.onStart - callback function of a new request queue
   * @param {FetchQL~requestQueueChanged=} options.onEnd - callback function of request queue finished
   * @param {Boolean=} options.omitEmptyVariables - remove null props(null or '') from the variables
   * @param {Object=} options.requestOptions - addition options to fetch request(refer to fetch api)
   */
  constructor({
     url,
     interceptors,
     headers,
     onStart,
     onEnd,
     omitEmptyVariables = false,
     requestOptions = {},
  }) {
    super();

    this.requestObject = Object.assign(
      {},
      {
        method: 'POST',
        headers: Object.assign({}, {
          Accept: 'application/json',
          'Content-Type': 'application/json',
        }, headers),
        credentials: 'same-origin',
      },
      requestOptions,
    );

    this.url = url;

    this.omitEmptyVariables = omitEmptyVariables;

    // marker for request queue
    this.requestQueueLength = 0;

    // using for caching enums' type
    this.EnumMap = {};

    this.callbacks = {
      onStart,
      onEnd,
    };

    this.addInterceptors(interceptors);
  }

  /**
   * operate a query
   * @param {Object} options
   * @param {String} options.operationName
   * @param {String} options.query
   * @param {Object=} options.variables
   * @param {Object=} options.opts - addition options(will not be passed to server)
   * @param {Boolean=} options.opts.omitEmptyVariables - remove null props(null or '') from the variables
   * @param {Object=} options.requestOptions - addition options to fetch request(refer to fetch api)
   * @returns {Promise}
   * @memberOf FetchQL
   */
  query({ operationName, query, variables, opts = {}, requestOptions = {}, }) {
    const options = Object.assign({}, this.requestObject, requestOptions);
    let vars;
    if (this.omitEmptyVariables || opts.omitEmptyVariables) {
      vars = this.doOmitEmptyVariables(variables);
    } else {
      vars = variables;
    }
    const body = {
      operationName,
      query,
      variables: vars,
    };
    options.body = JSON.stringify(body);

    this.onStart();

    return this.fetch(this.url, options)
      .then((res) => {
        if (res.ok) {
          return res.json();
        }
        // return an custom error stack if request error
        return {
          errors: [{
            message: res.statusText,
            stack: res,
          }],
        };
      })
      .then(({ data, errors }) => (
        new Promise((resolve, reject) => {
          this.onEnd();

          // if data in response is 'null'
          if (!data) {
            return reject(errors || [{}]);
          }
          // if all properties of data is 'null'
          const allDataKeyEmpty = Object.keys(data).every(key => !data[key]);
          if (allDataKeyEmpty) {
            return reject(errors);
          }
          return resolve({ data, errors });
        })
      ));
  }

  /**
   * get current server address
   * @returns {String}
   * @memberOf FetchQL
   */
  getUrl() {
    return this.url;
  }

  /**
   * setting a new server address
   * @param {String} url
   * @memberOf FetchQL
   */
  setUrl(url) {
    this.url = url;
  }

  /**
   * get information of enum type
   * @param {String[]} EnumNameList - array of enums' name
   * @returns {Promise}
   * @memberOf FetchQL
   */
  getEnumTypes(EnumNameList) {
    const fullData = {};

    // check cache status
    const unCachedEnumList = EnumNameList.filter((element) => {
      if (this.EnumMap[element]) {
        // enum has been cached
        fullData[element] = this.EnumMap[element];
        return false;
      }
      return true;
    });

    // immediately return the data if all enums have been cached
    if (!unCachedEnumList.length) {
      return new Promise((resolve) => {
        resolve({ data: fullData });
      });
    }

    // build query string for uncached enums
    const EnumTypeQuery = unCachedEnumList.map(type => (
      `${type}: __type(name: "${type}") {
        ...EnumFragment
      }`
    ));

    const query = `
      query {
        ${EnumTypeQuery.join('\n')}
      }
      
      fragment EnumFragment on __Type {
        kind
        description
        enumValues {
          name
          description
        }
      }`;

    const options = Object.assign({}, this.requestObject);
    options.body = JSON.stringify({ query });

    this.onStart();

    return this.fetch(this.url, options)
      .then((res) => {
        if (res.ok) {
          return res.json();
        }
        // return an custom error stack if request error
        return {
          errors: [{
            message: res.statusText,
            stack: res,
          }],
        };
      })
      .then(({ data, errors }) => (
        new Promise((resolve, reject) => {
          this.onEnd();

          // if data in response is 'null' and have any errors
          if (!data) {
            return reject(errors || [{ message: 'Do not get any data.' }]);
          }
          // if all properties of data is 'null'
          const allDataKeyEmpty = Object.keys(data).every(key => !data[key]);
          if (allDataKeyEmpty && errors && errors.length) {
            return reject(errors);
          }
          // merge enums' data
          const passData = Object.assign(fullData, data);
          // cache new enums' data
          Object.keys(data).map((key) => {
            this.EnumMap[key] = data[key];
            return key;
          });
          return resolve({ data: passData, errors });
        })
      ));
  }

  /**
   * calling on a request starting
   * if the request belong to a new queue, call the 'onStart' method
   */
  onStart() {
    this.requestQueueLength++;
    if (this.requestQueueLength > 1 || !this.callbacks.onStart) {
      return;
    }
    this.callbacks.onStart(this.requestQueueLength);
  }

  /**
   * calling on a request ending
   * if current queue finished, calling the 'onEnd' method
   */
  onEnd() {
    this.requestQueueLength--;
    if (this.requestQueueLength || !this.callbacks.onEnd) {
      return;
    }
    this.callbacks.onEnd(this.requestQueueLength);
  }

  /**
   * Callback of requests queue changes.(e.g. new queue or queue finished)
   * @callback FetchQL~requestQueueChanged
   * @param {number} queueLength - length of current request queue
   */

  /**
   * remove empty props(null or '') from object
   * @param {Object} input
   * @returns {Object}
   * @memberOf FetchQL
   * @private
   */
  doOmitEmptyVariables(input) {
    const nonEmptyObj = {};
    Object.keys(input).map(key => {
      const value = input[key];
      if ((typeof value === 'string' && value.length === 0) || value === null || value === undefined) {
        return key;
      } else if (value instanceof Object) {
        nonEmptyObj[key] = this.doOmitEmptyVariables(value);
      } else {
        nonEmptyObj[key] = value;
      }
      return key;
    });
    return nonEmptyObj;
  }
}

export default FetchQL;