modules/browse.js

/* eslint-disable max-len */
/* eslint-disable object-curly-newline, no-underscore-dangle, max-params */
const qs = require('qs');
const { AbortController } = require('node-abort-controller');
const helpers = require('../utils/helpers');

// Create query params from parameters and options
function createQueryParams(parameters, userParameters, options) {
  const {
    apiKey,
    version,
  } = options;
  const {
    sessionId,
    clientId,
    userId,
    segments,
    testCells,
  } = userParameters;
  let queryParams = { c: version };

  queryParams.key = apiKey;
  queryParams.i = clientId;
  queryParams.s = sessionId;

  if (parameters) {
    const {
      page,
      offset,
      resultsPerPage,
      filters,
      sortBy,
      sortOrder,
      section,
      fmtOptions,
      hiddenFields,
      hiddenFacets,
      variationsMap,
      preFilterExpression,
      qsParam,
    } = parameters;

    // Pull page from parameters
    if (!helpers.isNil(page)) {
      queryParams.page = page;
    }

    // Pull offset from parameters
    if (!helpers.isNil(offset)) {
      queryParams.offset = offset;
    }

    // Pull results per page from parameters
    if (!helpers.isNil(resultsPerPage)) {
      queryParams.num_results_per_page = resultsPerPage;
    }

    if (filters) {
      queryParams.filters = filters;
    }

    // Pull sort by from parameters
    if (sortBy) {
      queryParams.sort_by = sortBy;
    }

    // Pull sort order from parameters
    if (sortOrder) {
      queryParams.sort_order = sortOrder;
    }

    // Pull section from parameters
    if (section) {
      queryParams.section = section;
    }

    // Pull format options from parameters
    if (fmtOptions) {
      queryParams.fmt_options = fmtOptions;
    }

    // Pull hidden fields from parameters
    if (hiddenFields) {
      if (queryParams.fmt_options) {
        queryParams.fmt_options.hidden_fields = hiddenFields;
      } else {
        queryParams.fmt_options = { hidden_fields: hiddenFields };
      }
    }

    // Pull hidden facets from parameters
    if (hiddenFacets) {
      if (queryParams.fmt_options) {
        queryParams.fmt_options.hidden_facets = hiddenFacets;
      } else {
        queryParams.fmt_options = { hidden_facets: hiddenFacets };
      }
    }

    // Pull variations map from parameters
    if (variationsMap) {
      queryParams.variations_map = JSON.stringify(variationsMap);
    }

    // Pull filter expression from parameters
    if (preFilterExpression) {
      queryParams.pre_filter_expression = JSON.stringify(preFilterExpression);
    }

    // Pull qs param from parameters
    if (qsParam) {
      queryParams.qs = JSON.stringify(qsParam);
    }
  }

  // Pull test cells from options
  if (testCells) {
    Object.keys(testCells).forEach((testCellKey) => {
      queryParams[`ef-${testCellKey}`] = testCells[testCellKey];
    });
  }

  // Pull user segments from options
  if (segments && segments.length) {
    queryParams.us = segments;
  }

  // Pull user id from options and ensure string
  if (userId) {
    queryParams.ui = String(userId);
  }

  queryParams._dt = Date.now();
  queryParams = helpers.cleanParams(queryParams);

  return queryParams;
}

// Create URL from supplied filter name, value and parameters
function createBrowseUrlFromFilter(filterName, filterValue, parameters, userParameters, options) {
  const { serviceUrl } = options;

  // Validate filter name is provided
  if (!filterName || typeof filterName !== 'string') {
    throw new Error('filterName is a required parameter of type string');
  }

  // Validate filter value is provided
  if (!filterValue || typeof filterValue !== 'string') {
    throw new Error('filterValue is a required parameter of type string');
  }

  const queryParams = createQueryParams(parameters, userParameters, options);
  const queryString = qs.stringify(queryParams, { indices: false });

  return `${serviceUrl}/browse/${helpers.encodeURIComponentRFC3986(helpers.normalizeSpaces(filterName).trim())}/${helpers.encodeURIComponentRFC3986(helpers.normalizeSpaces(filterValue).trim())}?${queryString}`;
}

// Create URL from supplied IDs and parameters
function createBrowseUrlFromIDs(itemIds, parameters, userParameters, options) {
  const { serviceUrl } = options;

  // Validate item IDs are provided
  if (!itemIds || !(itemIds instanceof Array) || !itemIds.length) {
    throw new Error('itemIds is a required parameter of type array');
  }

  const queryParams = { ...createQueryParams(parameters, userParameters, options), ids: itemIds };
  const queryString = qs.stringify(queryParams, { indices: false });

  return `${serviceUrl}/browse/items?${queryString}`;
}

// Create URL from supplied parameters
function createBrowseUrlForFacets(parameters, userParameters, options) {
  const { serviceUrl } = options;
  const queryParams = { ...createQueryParams(parameters, userParameters, options) };

  delete queryParams._dt;

  const queryString = qs.stringify(queryParams, { indices: false });

  return `${serviceUrl}/browse/facets?${queryString}`;
}

// Create URL from supplied facet name and parameters
function createBrowseUrlForFacetOptions(facetName, parameters, userParameters, options) {
  const { serviceUrl } = options;

  // Validate facet name is provided
  if (!facetName || typeof facetName !== 'string') {
    throw new Error('facetName is a required parameter of type string');
  }

  const queryParams = { ...createQueryParams(parameters, userParameters, options) };

  queryParams.facet_name = facetName;

  delete queryParams._dt;

  const queryString = qs.stringify(queryParams, { indices: false });

  return `${serviceUrl}/browse/facet_options?${queryString}`;
}

// Create request headers using supplied options and user parameters
function createHeaders(options, userParameters, networkParameters = {}) {
  const headers = {};

  Object.assign(headers, helpers.combineCustomHeaders(options, networkParameters));

  // Append security token as 'x-cnstrc-token' if available
  if (options.securityToken && typeof options.securityToken === 'string') {
    headers['x-cnstrc-token'] = options.securityToken;
  }

  // Append user IP as 'X-Forwarded-For' if available
  if (userParameters.userIp && typeof userParameters.userIp === 'string') {
    headers['X-Forwarded-For'] = userParameters.userIp;
  }

  // Append user agent as 'User-Agent' if available
  if (userParameters.userAgent && typeof userParameters.userAgent === 'string') {
    headers['User-Agent'] = userParameters.userAgent;
  }

  return headers;
}

/**
 * Interface to browse related API calls
 *
 * @module browse
 * @inner
 * @returns {object}
 */
class Browse {
  constructor(options) {
    this.options = options || {};
  }

  /**
   * Retrieve browse results from API
   *
   * @function getBrowseResults
   * @param {string} filterName - Filter name to display results from
   * @param {string} filterValue - Filter value to display results from
   * @param {object} [parameters] - Additional parameters to refine result set
   * @param {number} [parameters.page] - The page number of the results. Can't be used together with 'offset'
   * @param {number} [parameters.offset] - The number of results to skip from the beginning. Can't be used together with 'page'
   * @param {number} [parameters.resultsPerPage] - The number of results per page to return
   * @param {object} [parameters.filters] - Filters used to refine results
   * @param {string} [parameters.sortBy='relevance'] - The sort method for results
   * @param {string} [parameters.sortOrder='descending'] - The sort order for results
   * @param {string} [parameters.section='Products'] - The section name for results
   * @param {object} [parameters.fmtOptions] - The format options used to refine result groups
   * @param {string[]} [parameters.hiddenFields] - Hidden metadata fields to return
   * @param {string[]} [parameters.hiddenFacets] - Hidden facet fields to return
   * @param {object} [parameters.variationsMap] - The variations map object to aggregate variations. Please refer to https://docs.constructor.io/rest_api/variations_mapping for details
   * @param {object} [parameters.preFilterExpression] - Faceting expression to scope search results. Please refer to https://docs.constructor.io/rest_api/collections/#add-items-dynamically for details
   * @param {object} [parameters.qsParam] - Parameters listed above can be serialized into a JSON object and parsed through this parameter. Please refer to https://docs.constructor.io/rest_api/browse/queries for details
   * @param {object} [userParameters] - Parameters relevant to the user request
   * @param {number} [userParameters.sessionId] - Session ID, utilized to personalize results
   * @param {string} [userParameters.clientId] - Client ID, utilized to personalize results
   * @param {string} [userParameters.userId] - User ID, utilized to personalize results
   * @param {string[]} [userParameters.segments] - User segments
   * @param {object} [userParameters.testCells] - User test cells
   * @param {string} [userParameters.userIp] - Origin user IP, from client
   * @param {string} [userParameters.userAgent] - Origin user agent, from client
   * @param {object} [networkParameters] - Parameters relevant to the network request
   * @param {number} [networkParameters.timeout] - Request timeout (in milliseconds)
   * @returns {Promise}
   * @see https://docs.constructor.io/rest_api/browse/results
   * @example
   * constructorio.browse.getBrowseResults('group_id', 't-shirts', {
   *     resultsPerPage: 40,
   *     filters: {
   *         size: 'medium'
   *     },
   * });
   */
  getBrowseResults(filterName, filterValue, parameters = {}, userParameters = {}, networkParameters = {}) {
    let requestUrl;
    const { fetch } = this.options;
    const controller = new AbortController();
    const { signal } = controller;
    const headers = createHeaders(this.options, userParameters, networkParameters);

    try {
      requestUrl = createBrowseUrlFromFilter(filterName, filterValue, parameters, userParameters, this.options);
    } catch (e) {
      return Promise.reject(e);
    }

    // Handle network timeout if specified
    helpers.applyNetworkTimeout(this.options, networkParameters, controller);

    return fetch(requestUrl, { headers, signal }).then((response) => {
      if (response.ok) {
        return response.json();
      }

      return helpers.throwHttpErrorFromResponse(new Error(), response);
    }).then((json) => {
      // Browse results
      if (json.response && json.response.results) {
        if (json.result_id) {
          json.response.results.forEach((result) => {
            // eslint-disable-next-line no-param-reassign
            result.result_id = json.result_id;
          });
        }

        return json;
      }

      // Redirect rules
      if (json.response && json.response.redirect) {
        return json;
      }

      throw new Error('getBrowseResults response data is malformed');
    });
  }

  /**
   * Retrieve browse results from API using item IDs
   *
   * @function getBrowseResultsForItemIds
   * @param {string[]} itemIds - Item IDs of results to fetch
   * @param {object} [parameters] - Additional parameters to refine result set
   * @param {number} [parameters.page] - The page number of the results. Can't be used together with 'offset'
   * @param {number} [parameters.offset] - The number of results to skip from the beginning. Can't be used together with 'page'
   * @param {number} [parameters.resultsPerPage] - The number of results per page to return
   * @param {object} [parameters.filters] - Filters used to refine results
   * @param {string} [parameters.sortBy='relevance'] - The sort method for results
   * @param {string} [parameters.sortOrder='descending'] - The sort order for results
   * @param {string} [parameters.section='Products'] - The section name for results
   * @param {object} [parameters.fmtOptions] - The format options used to refine result groups
   * @param {string[]} [parameters.hiddenFields] - Hidden metadata fields to return
   * @param {string[]} [parameters.hiddenFacets] - Hidden facet fields to return
   * @param {object} [parameters.variationsMap] - The variations map object to aggregate variations. Please refer to https://docs.constructor.io/rest_api/variations_mapping for details
   * @param {object} [userParameters] - Parameters relevant to the user request
   * @param {number} [userParameters.sessionId] - Session ID, utilized to personalize results
   * @param {string} [userParameters.clientId] - Client ID, utilized to personalize results
   * @param {string} [userParameters.userId] - User ID, utilized to personalize results
   * @param {string[]} [userParameters.segments] - User segments
   * @param {object} [userParameters.testCells] - User test cells
   * @param {string} [userParameters.userIp] - Origin user IP, from client
   * @param {string} [userParameters.userAgent] - Origin user agent, from client
   * @param {object} [networkParameters] - Parameters relevant to the network request
   * @param {number} [networkParameters.timeout] - Request timeout (in milliseconds)
   * @returns {Promise}
   * @see https://docs.constructor.io/rest_api/browse/items/
   * @example
   * constructorio.browse.getBrowseResultsForItemIds(['shirt-123', 'shirt-456', 'shirt-789'], {
   *     filters: {
   *         size: 'medium'
   *     },
   * });
   */
  getBrowseResultsForItemIds(itemIds, parameters = {}, userParameters = {}, networkParameters = {}) {
    let requestUrl;
    const { fetch } = this.options;
    const controller = new AbortController();
    const { signal } = controller;
    const headers = createHeaders(this.options, userParameters, networkParameters);

    try {
      requestUrl = createBrowseUrlFromIDs(itemIds, parameters, userParameters, this.options);
    } catch (e) {
      return Promise.reject(e);
    }

    // Handle network timeout if specified
    helpers.applyNetworkTimeout(this.options, networkParameters, controller);

    return fetch(requestUrl, { headers, signal })
      .then((response) => {
        if (response.ok) {
          return response.json();
        }

        return helpers.throwHttpErrorFromResponse(new Error(), response);
      })
      .then((json) => {
        if (json.response && json.response.results) {
          if (json.result_id) {
            // Append `result_id` to each result item
            json.response.results.forEach((result) => {
              // eslint-disable-next-line no-param-reassign
              result.result_id = json.result_id;
            });
          }
          return json;
        }
        throw new Error('getBrowseResultsForItemIds response data is malformed');
      });
  }

  /**
   * Retrieve browse groups from API
   *
   * @function getBrowseGroups
   * @param {object} [parameters.filters] - Filters used to refine results
   * @param {string} [parameters.section='Products'] - The section name for results
   * @param {object} [parameters.fmtOptions] - The format options used to refine result groups
   * @param {object} [userParameters] - Parameters relevant to the user request
   * @param {number} [userParameters.sessionId] - Session ID, utilized to personalize results
   * @param {string} [userParameters.clientId] - Client ID, utilized to personalize results
   * @param {string} [userParameters.userId] - User ID, utilized to personalize results
   * @param {string[]} [userParameters.segments] - User segments
   * @param {object} [userParameters.testCells] - User test cells
   * @param {string} [userParameters.userIp] - Origin user IP, from client
   * @param {string} [userParameters.userAgent] - Origin user agent, from client
   * @param {object} [networkParameters] - Parameters relevant to the network request
   * @param {number} [networkParameters.timeout] - Request timeout (in milliseconds)
   * @returns {Promise}
   * @see https://docs.constructor.io/rest_api/browse/groups
   * @example
   * constructorio.browse.getBrowseGroups({
   *     filters: {
   *         group_id: 'drill_collection'
   *     },
   *     fmtOptions: {
   *         groups_max_depth: 2
   *     }
   * });
   */
  getBrowseGroups(parameters = {}, userParameters = {}, networkParameters = {}) {
    const { fetch } = this.options;
    const controller = new AbortController();
    const { signal } = controller;
    const headers = createHeaders(this.options, userParameters, networkParameters);
    const { serviceUrl } = this.options;
    const queryParams = createQueryParams(parameters, userParameters, this.options);

    delete queryParams._dt;

    const queryString = qs.stringify(queryParams, { indices: false });
    const requestUrl = `${serviceUrl}/browse/groups?${queryString}`;

    // Handle network timeout if specified
    helpers.applyNetworkTimeout(this.options, networkParameters, controller);

    return fetch(requestUrl, { headers, signal }).then((response) => {
      if (response.ok) {
        return response.json();
      }

      return helpers.throwHttpErrorFromResponse(new Error(), response);
    });
  }

  /**
   * Retrieve facets from API
   *
   * @function getBrowseFacets
   * @param {object} [parameters] - Additional parameters to refine result set
   * @param {number} [parameters.page] - The page number of the results. Can't be used together with 'offset'
   * @param {number} [parameters.offset] - The number of results to skip from the beginning. Can't be used together with 'page'
   * @param {string} [parameters.section='Products'] - The section name for results
   * @param {number} [parameters.resultsPerPage] - The number of results per page to return
   * @param {object} [parameters.fmtOptions] - The format options used to refine result groups
   * @param {boolean} [parameters.fmtOptions.show_hidden_facets] - Include facets configured as hidden
   * @param {boolean} [parameters.fmtOptions.show_protected_facets] - Include facets configured as protected
   * @param {object} [userParameters] - Parameters relevant to the user request
   * @param {number} [userParameters.sessionId] - Session ID, utilized to personalize results
   * @param {string} [userParameters.clientId] - Client ID, utilized to personalize results
   * @param {string} [userParameters.userId] - User ID, utilized to personalize results
   * @param {string[]} [userParameters.segments] - User segments
   * @param {object} [userParameters.testCells] - User test cells
   * @param {string} [userParameters.userIp] - Origin user IP, from client
   * @param {string} [userParameters.userAgent] - Origin user agent, from client
   * @param {object} [networkParameters] - Parameters relevant to the network request
   * @param {number} [networkParameters.timeout] - Request timeout (in milliseconds)
   * @returns {Promise}
   * @see https://docs.constructor.io/rest_api/browse/facets
   * @example
   * constructorio.browse.getBrowseFacets({
   *     page: 1,
   *     resultsPerPage: 10,
   * });
   */
  getBrowseFacets(parameters = {}, userParameters = {}, networkParameters = {}) {
    let requestUrl;
    const { fetch } = this.options;
    const controller = new AbortController();
    const { signal } = controller;
    const headers = createHeaders(this.options, userParameters, networkParameters);

    try {
      requestUrl = createBrowseUrlForFacets(parameters, userParameters, this.options);
    } catch (e) {
      return Promise.reject(e);
    }

    // Handle network timeout if specified
    helpers.applyNetworkTimeout(this.options, networkParameters, controller);

    return fetch(requestUrl, {
      headers: { ...headers, ...helpers.createAuthHeader(this.options) },
      signal,
    }).then((response) => {
      if (response.ok) {
        return response.json();
      }

      return helpers.throwHttpErrorFromResponse(new Error(), response);
    });
  }

  /**
   * Retrieve facet options from API
   *
   * @function getBrowseFacetOptions
   * @param {string} facetName - Name of the facet whose options to return
   * @param {object} [parameters] - Additional parameters to refine result set
   * @param {string} [parameters.section='Products'] - The section name for results
   * @param {object} [parameters.fmtOptions] - The format options used to refine result groups
   * @param {boolean} [parameters.fmtOptions.show_hidden_facets] - Include facets configured as hidden
   * @param {boolean} [parameters.fmtOptions.show_protected_facets] - Include facets configured as protected
   * @param {object} [userParameters] - Parameters relevant to the user request
   * @param {number} [userParameters.sessionId] - Session ID, utilized to personalize results
   * @param {string} [userParameters.clientId] - Client ID, utilized to personalize results
   * @param {string} [userParameters.userId] - User ID, utilized to personalize results
   * @param {string[]} [userParameters.segments] - User segments
   * @param {object} [userParameters.testCells] - User test cells
   * @param {string} [userParameters.userIp] - Origin user IP, from client
   * @param {string} [userParameters.userAgent] - Origin user agent, from client
   * @param {object} [networkParameters] - Parameters relevant to the network request
   * @param {number} [networkParameters.timeout] - Request timeout (in milliseconds)
   * @returns {Promise}
   * @see https://docs.constructor.io/rest_api/browse/facet_options
   * @example
   * constructorio.browse.getBrowseFacetOptions('price', {
   *     fmtOptions: { ... },
   * });
   */
  getBrowseFacetOptions(facetName, parameters = {}, userParameters = {}, networkParameters = {}) {
    let requestUrl;
    const { fetch } = this.options;
    const controller = new AbortController();
    const { signal } = controller;
    const headers = createHeaders(this.options, userParameters, networkParameters);

    try {
      requestUrl = createBrowseUrlForFacetOptions(facetName, parameters, userParameters, this.options);
    } catch (e) {
      return Promise.reject(e);
    }

    // Handle network timeout if specified
    helpers.applyNetworkTimeout(this.options, networkParameters, controller);

    return fetch(requestUrl, {
      headers: { ...headers, ...helpers.createAuthHeader(this.options) },
      signal,
    }).then((response) => {
      if (response.ok) {
        return response.json();
      }

      return helpers.throwHttpErrorFromResponse(new Error(), response);
    });
  }
}

module.exports = Browse;