utils.ts

/* global fetch */
import { v1 as uuidv1 } from 'uuid'
import dig from 'lodash/get'
import flattenDeep from 'lodash/flattenDeep'
import { toJS } from 'mobx'
import qs from 'qs'

const pending = {}
const counter = {}
export const URL_MAX_LENGTH = 1024
const ENCODED_COMMA = encodeURIComponent(',')

/**
 * Strips observers and returns a plain JS array
 *
 * @param {Array} array the array to transform
 * @returns {Array} the "clean array"
 */
export const arrayType = (array) => toJS(array)

/**
 * Strips observers and returns a plain JS object
 *
 * @param {object} object the object to transform
 * @returns {object} the "clean object"
 */
export const objectType = (object) => toJS(object)

/**
 * Coerces a string or date to a date
 *
 * @param {Date|string} date the date to transform
 * @returns {Date} a date
 */
export const dateType = (date) => makeDate(date).toISOString()

/**
 * Coerces a value to a string
 *
 * @param {number|string} value the value to transform
 * @returns {string} a string
 */
export const stringType = (value) => value.toString()

/**
 * Coerces a value to a number
 *
 * @param {number|string} value the value to transform
 * @returns {number} a number
 */
export const numberType = (value) => Number(value)

/**
 * Increments a counter by 1
 *
 * @param {string} key the counter to increment
 * @returns {number} the current count
 */
const incrementor = (key) => () => {
  const count = (counter[key] || 0) + 1
  counter[key] = count
  return count
}

/**
 * Decreases a counter by 1
 *
 * @param {string} key the counter to decreases
 * @returns {number} the current count
 */
const decrementor = (key) => () => {
  const count = (counter[key] || 0) - 1
  counter[key] = count
  return count
}

/**
 * Build request url from base url, endpoint, query params, and ids.
 *
 * @param {string} baseUrl the base url
 * @param {string} endpoint the endpoint of the url
 * @param {object} queryParams query params to add
 * @param {string} id the id of the the model
 * @returns {string} formatted url string
 */
export function requestUrl (baseUrl, endpoint, queryParams = {}, id) {
  let queryParamString = ''
  if (Object.keys(queryParams).length > 0) {
    queryParamString = `?${QueryString.stringify(queryParams)}`
  }
  let idForPath = ''
  if (id) {
    idForPath = `/${id}`
  }
  // Return full url
  return `${baseUrl}/${endpoint}${idForPath}${queryParamString}`
}

/**
 * Generates a temporary id to be used for reference in the store
 *
 * @returns {string} a uuidv1 string prefixed with `tmp`
 */
export function newId () {
  return `tmp-${uuidv1()}`
}

/**
 * Avoids making racing requests by blocking a request if an identical one is
 * already in-flight. Blocked requests will be resolved when the initial request
 * resolves by cloning the response.
 *
 
 * @param {string} key the unique key for the request
 * @param {Function} fn the function the generates the promise
 * @returns {Promise} the request
 */
export function combineRacedRequests (key, fn) {
  const incrementBlocked = incrementor(key)
  const decrementBlocked = decrementor(key)

  // keep track of the number of callers waiting for this promise to resolve
  incrementBlocked()

  // Add the current call to our pending list in case another request comes in
  // before it resolves. If there is a request already pending, we'll use the
  // existing one instead
  if (!pending[key]) { pending[key] = fn.call() }

  return pending[key]
    .finally(() => {
      const count = decrementBlocked()
      // if there are no more callers waiting for this promise to resolve (i.e. if
      // this is the last one), we can remove the reference to the pending promise
      // allowing subsequent requests to proceed unblocked.
      if (count === 0) delete pending[key]
    })
    .then(
      // if there are other callers waiting for this request to resolve, clone the
      // response before returning so that we can re-use it for the remaining callers
      response => response.clone(),
      // Bubble the error up to be handled by the consuming code
      error => Promise.reject(error)
    )
}

/**
 * Implements a retry in case a fetch fails
 *
 * @param {string} url the request url
 * @param {object} fetchOptions headers etc to use for the request
 * @param {number} attempts number of attempts to try
 * @param {number} delay time between attempts
 * @returns {Promise} the fetch
 */
export function fetchWithRetry (url, fetchOptions, attempts, delay) {
  const key = JSON.stringify({ url, fetchOptions })

  return combineRacedRequests(key, () => fetch(url, fetchOptions))
    .catch(error => {
      const attemptsRemaining = attempts - 1
      if (!attemptsRemaining) { throw error }
      return new Promise((resolve) => setTimeout(resolve, delay))
        .then(() => fetchWithRetry(url, fetchOptions, attemptsRemaining, delay))
    })
}

/**
 * convert a value into a date, pass Date or Moment instances thru
 * untouched
 
 * @param {Date|string} value a date-like object
 * @returns {Date} a date object
 */
export function makeDate (value) {
  if (value instanceof Date || value._isAMomentObject) return value
  return new Date(Date.parse(value))
}

/**
 * recursively walk an object and call the `iteratee` function for
 * each property. returns an array of results of calls to the iteratee.
 
 * @param {object} obj the object to analyze
 * @param {Function} iteratee the iterator to use
 * @param {string} prefix the prefix
 * @returns {Array} the result of iteratee calls
 */
export function walk (obj, iteratee, prefix) {
  if (obj != null && typeof obj === 'object') {
    return Object.keys(obj).map((prop) => {
      return walk(obj[prop], iteratee, [prefix, prop].filter(x => x).join('.'))
    })
  }
  return iteratee(obj, prefix)
}

/**
 * deeply compare objects a and b and return object paths for attributes
 * which differ. it is important to note that this comparison is biased
 * toward object a. object a is walked and compared against values in
 * object b. if a property exists in object b, but not in object a, it
 * will not be counted as a difference.
 
 * @param {object} a the first object
 * @param {object} b the second object
 * @returns {string[]} the path to differences
 */
export function diff (a = {}, b = {}) {
  return flattenDeep(walk(a, (prevValue, path) => {
    const currValue = dig(b, path)
    return prevValue === currValue ? undefined : path
  })).filter((x) => x)
}

/**
 * Parses JSONAPI error objects from a fetch response.
 * If the response's body is undefined or is not formatted with a top-level `errors` key
 * containing an array of errors, it builds a JSONAPI error object from the response status
 * and a `errorMessages` configuration.
 *
 * Errors that are returned which contain a status also have their `detail` overridden with
 * values from this configuration.
 *
 * @param {object} response  a fetch response
 * @param {object} errorMessages store configuration of error messages corresponding to HTTP status codes
 * @returns {object[]} An array of JSONAPI errors
 */
export async function parseErrors (response, errorMessages) {
  let json = {}
  try {
    json = await response.json()
  } catch (error) {
    // server doesn't return a parsable response
    const statusError = {
      detail: errorMessages[response.status] || errorMessages.default,
      status: response.status
    }
    return [statusError]
  }

  if (!json.errors) {
    const statusError = {
      detail: errorMessages[response.status] || errorMessages.default,
      status: response.status
    }
    return [statusError]
  }

  if (!Array.isArray(json.errors)) {
    const statusError = {
      detail: 'Top level errors in response are not an array.',
      status: response.status
    }
    return [statusError]
  }

  return json.errors.map((error) => {
    // override or add the configured error message based on response status
    if (error.status && errorMessages[error.status]) {
      error.detail = errorMessages[error.status]
    }
    return error
  })
}

/**
 * Parses the pointer of the error to retrieve the index of the
 * record the error belongs to and the full path to the attribute
 * which will serve as the key for the error.
 *
 * If there is no parsed index, then assume the payload was for
 * a single record and default to 0.
 *
 * ex.
 *   error = {
 *     detail: "Foo can't be blank",
 *     source: { pointer: '/data/1/attributes/options/foo' },
 *     title: 'Invalid foo'
 *   }
 *
 * parsePointer(error)
 * > {
 *     index: 1,
 *     key: 'options.foo'
 *   }
 *
 * @param {object} error the error object to parse
 * @returns {object} the matching parts of the pointer
 */
export function parseErrorPointer (error = {}) {
  const regex = /\/data\/(?<index>\d+)?\/?attributes\/(?<key>.*)$/
  const match = dig(error, 'source.pointer', '').match(regex)
  const { index = 0, key } = match?.groups || {}

  return {
    index: parseInt(index),
    key: key?.replace(/\//g, '.')
  }
}

/**
 * Splits an array of ids into a series of strings that can be used to form
 * queries that conform to a max length of URL_MAX_LENGTH. This is to prevent 414 errors.
 *
 * @param {Array} ids an array of ids that will be used in the string
 * @param {string} restOfUrl the additional text URL that will be passed to the server
 * @returns {string[]} an array of strings of ids
 */
export function deriveIdQueryStrings (ids, restOfUrl = '') {
  const maxLength = URL_MAX_LENGTH - restOfUrl.length - encodeURIComponent('filter[ids]=,,').length

  ids = ids.map(String)
  const firstId = ids.shift()

  const encodedIds = ids.reduce((nestedArray, id) => {
    const workingString = nestedArray[nestedArray.length - 1]
    const longerString = `${workingString}${ENCODED_COMMA}${id}`

    if (longerString.length < maxLength) {
      nestedArray[nestedArray.length - 1] = longerString
    } else {
      nestedArray.push(id)
    }

    return nestedArray
  }, [firstId])

  return encodedIds.map(decodeURIComponent)
}

/**
 * Returns true if the value is an empty string
 *
 * @param {any} value the value to check
 * @returns {boolean} true if the value is an empty string
 */
export const isEmptyString = (value) => typeof value === 'string' && value.trim().length === 0

/**
 * returns `true` as long as the `value` is not `null`, `undefined`, or `''`
 *
 * @function validatePresence
 * @returns {object} a validation object
 */
export const validatesPresence = () => {
  return {
    /**
     * Returns `true` if the value is truthy
     *
     * @param {any} value the value to check
     * @returns {boolean} true if the value is present
     */
    isValid: (value) => value != null && value !== '',
    errors: [{
      key: 'blank',
      message: 'can\'t be blank'
    }]
  }
}

/**
 * Is valid if the value is not an empty string
 *
 * @param {string} value the value to check
 * @returns {object} a validation object
 */
export const validatesString = (value) => {
  return {
    isValid: !isEmptyString(value),
    errors: [{
      key: 'blank',
      message: "can't be blank"
    }]
  }
}

/**
 * Returns valid if the value is an array
 *
 * @param {any} value the value to check
 * @returns {object} a validation object
 */
export const validatesArray = (value) => {
  return {
    isValid: Array.isArray(value),
    errors: [{
      key: 'must_be_an_array',
      message: 'must be an array'
    }]
  }
}

/**
 * Is valid if the array has at least one object
 *
 * @param {Array} array the array to check
 * @returns {object} a validation object
 */
export const validatesArrayPresence = (array) => {
  return {
    isValid: Array.isArray(array) && array.length > 0,
    errors: [{
      key: 'empty',
      message: 'must have at least one record'
    }]
  }
}

/**
 * Valid if target options are not blank
 *
 * @param {string} property the options key to check
 * @param {object} target the object
 * @returns {object} a validation object
 */
export const validatesOptions = (property, target) => {
  const errors = []

  if (target.requiredOptions) {
    target.requiredOptions.forEach(optionKey => {
      if (!property[optionKey]) {
        errors.push({
          key: 'blank',
          message: 'can\t be blank',
          data: { optionKey }
        })
      }
    })
  }

  return {
    isValid: errors.length === 0,
    errors
  }
}

/**
 * An object with default `parse` and `stringify` functions from qs
 */
export const QueryString = {
  /**
   * Parses a string and returns query params
   *
   * @param {string} str the url to parse
   * @returns {object} a query object
   */
  parse: (str) => qs.parse(str, { ignoreQueryPrefix: true }),
  /**
   * Changes an object to a string of query params
   *
   * @param {object} params object to stringify
   * @returns {string} the encoded params
   */
  stringify: (params) => qs.stringify(params, { arrayFormat: 'brackets' })
}