Source

api/index.js

/**
 * @namespace api
 * @category api
 * @module api-wrapper
 * */

import Vue from 'vue'
import axios from 'axios'
import qs from 'qs'
import fixtures from './fixtures'
import { getQueryStringValue } from '@/utils/querystring'
import { mitt } from '@/plugins/mitt.js'
import { isUnitTest } from '@/utils/unit.js'
import { useApiPlatformResources } from './api-platform-resources.js'
import {
  getCachedJWT,
  setCacheFromAPIResponse,
  getCacheFromAPIRequestURL,
} from './api-cache.js'
import { errorLogger } from '@/plugins/error-logger.js'
import { getNestedValue } from '../utils/object'
import APIUrls from '@/config/simpliciti-apis.js'

const shouldLog = isUnitTest()
  ? true
  : (getQueryStringValue('verbose') || '').includes('1') ||
    (getQueryStringValue('verbose') || '').includes('api')

const bus = {
  $on: (name, callback) => {
    return mitt.on(name, callback)
  },
  $emit: (name, payload) => {
    return mitt.emit(name, payload)
  },
}

const globalOptions = {
  headers: {
    accept: 'application/json',
  },
}

const defaultGetCacheFromAPIRequestURLOptions = {
  beforeCacheFetch(url) {
    //Location module: Skip cache if requesting data from today.
    if (url.includes(require('moment')().format('YYYY-MM-DD'))) {
      shouldLog &&
        console.log('beforeCacheFetch::skip-history-request-if-today')
      return false
    }
  },
}

/**
 *
 * @param {*} defaultOptions
 * @returns {Object}
 */
function getApi(defaultOptions = {}) {
  async function getRequestOptions(url, options = {}, data = null) {
    options = {
      ...globalOptions,
      ...defaultOptions,
      ...options,
    }
    let isPublicURL = !!url && url.includes('public')
    let jwt = null
    if (!isPublicURL && options.requestHandler === undefined) {
      jwt = options.jwt || options._token || (await getCachedJWT(url))
    }

    options.headers = {
      ...(options.headers || {}),
      Authorization: 'Bearer ' + jwt,
    }

    options.url += ('/' + url).split('//').join('/') //Concatenate with '/' if not present
    options.relativeUrl = url
    if (data) {
      options.data = data
    }
    return options
  }

  return {
    APIUrls,
    url: defaultOptions.url || globalOptions.url || '',
    bus,
    get: async function (url, data = {}, options = {}) {
      //Build full url before checking cache
      if (Object.keys(data).length > 0) {
        url =
          url +
          (url.indexOf('?') !== -1 ? '&' : '?') +
          Object.keys(data)
            .map((key) => {
              if (data[key] instanceof Array) {
                return data[key]
                  .map((item) => {
                    return `${key}[]=${item}`
                  })
                  .join('&')
              } else {
                return `${key}=${data[key]}`
              }
            })
            .join('&')
      }

      let cachedResponse = await getCacheFromAPIRequestURL(
        url,
        defaultGetCacheFromAPIRequestURLOptions,
        options
      )

      shouldLog &&
        console.log('api.get.request', {
          url,
          cached: !!cachedResponse,
        })

      if (cachedResponse) {
        return cachedResponse
      }

      let getOptions = {
        method: 'get',
        ...(await getRequestOptions(url, options)),
      }
      shouldLog && console.info('get', url)
      return await processAxios(
        createAxiosRequest(getOptions, options),
        getOptions,
        options,
        this
      )
    },
    post: async function (url, data = {}, options = {}) {
      let cachedResponse = await getCacheFromAPIRequestURL(
        url,
        defaultGetCacheFromAPIRequestURLOptions,
        options
      )
      if (cachedResponse) {
        return cachedResponse
      }

      let postOptions = {
        method: 'post',
        ...(await getRequestOptions(url, options, data)),
      }
      shouldLog &&
        console.log('api.post.request', {
          url: postOptions.url,
        })
      return await processAxios(axios(postOptions), postOptions, options)
    },
    delete: async function (url, data = {}, options = {}) {
      let requestOptions = {
        method: 'delete',
        ...(await getRequestOptions(url, options, data)),
      }
      shouldLog && console.info('delete', url)
      return await processAxios(axios(requestOptions), requestOptions, options)
    },
    patch: async function (url, data = {}, options = {}) {
      let requestOptions = {
        method: 'patch',
        ...(await getRequestOptions(url, options, data)),
      }
      shouldLog && console.info('patch', url)
      requestOptions.headers['Content-type'] = 'application/merge-patch+json'
      return await processAxios(axios(requestOptions), requestOptions, options)
    },
    postFormUrlEncoded: async function (url, data = {}, options = {}) {
      let cachedResponse = await getCacheFromAPIRequestURL(
        url,
        defaultGetCacheFromAPIRequestURLOptions,
        options
      )
      if (cachedResponse) {
        return cachedResponse
      }

      let postOptions = {
        method: 'post',
        ...(await getRequestOptions(url, options, qs.stringify(data))), //@warn: Why are we passing querystring values?
      }
      postOptions.headers['Content-type'] = 'application/x-www-form-urlencoded'
      postOptions.headers.accept = 'application/json'

      shouldLog && console.info('postFormUrlEncoded', url, postOptions, options)
      return await processAxios(axios(postOptions), postOptions, options)
    },
  }
}

/**
 * Unit tests can fake axios request using api.get(url, {requestHandler:()=>{}})
 * @param {Object} requestOptions computed request options
 * @param {Function} options.requestHandler Handler to fake axios request
 * @returns
 */
function createAxiosRequest(requestOptions, options = {}) {
  //console.log('createAxiosRequest', options)
  if (options.requestHandler) {
    let promiseOrNot = options.requestHandler(requestOptions)
    if (!(promiseOrNot instanceof Promise)) {
      return new Promise((resolve) => {
        resolve(promiseOrNot)
      })
    } else {
      return promiseOrNot
    }
  } else {
    return axios(requestOptions)
  }
}

const api = {
  loadFixtures: (identifier, transform) =>
    fixtures.loadFixtures(identifier, transform),
  toggleFixtures: (value) => fixtures.toggleFixtures(value),
  areFixturesEnabled: {
    get() {
      return fixtures.areFixturesEnabled
    },
  },

  ...getApi({
    ...globalOptions,
    url: process.env.VUE_APP_API_GEORED_HOST,
  }),
  getApi,
  v2: getApi({
    ...globalOptions,
    url: process.env.VUE_APP_API_GEORED_HOST,
  }),
  v3: getApi({
    ...globalOptions,
    withCredentials: false,
    url: process.env.VUE_APP_API_GEORED_V3_HOST,
  }),
}

Object.assign(
  api,
  useApiPlatformResources({
    apiWrapper: api,
    getAPIV3Pooling,
  })
)

/**
 * @deprecated Vue3-compatibility: Import module instead: import api from '@/api.js'
 */
Vue.use({
  install() {
    Vue.$api = Vue.prototype.$api = api
  },
})

export default api

function promptForDownload(
  axiosResponse,
  defaultFileName = 'filename',
  forceDefaultDownloaFilename = false
) {
  const disposition = axiosResponse.headers['content-disposition']
  const match = disposition && disposition.match(/filename=([^;]+)/)
  const filename = forceDefaultDownloaFilename
    ? defaultFileName
    : (match && match[1].trim()) || defaultFileName

  const url = window.URL.createObjectURL(new Blob([axiosResponse.data]))
  const link = document.createElement('a')
  link.href = url
  link.setAttribute('download', filename)
  document.body.appendChild(link)
  link.click()
}

async function processAxios(
  axiosPromise,
  requestOptions,
  operationOptions = {},
  methods = null
) {
  let res = {}
  try {
    res = await axiosPromise
    if (!res) {
      throw new Error('invalid_response')
    }
  } catch (err) {
    res = err
    if (!res) {
      res = new Error('unknown_error')
    }
  }

  if (res instanceof Error) {
    ;[401, 403, 500].forEach((errorCode) => {
      if (res.message.indexOf(errorCode.toString()) !== -1) {
        res.status = errorCode
        return false
      }
    })
  }

  if (res.status && !['200', '201'].includes(res.status.toString())) {
    console.warn('API Response', {
      status: res.status,
      //requestOptions,
      response: res.data || res,
    })
    bus.$emit(res.status.toString(), res)
  }

  if (res instanceof Error) {
    console.warn('api-wrapper::server-error', res.stack)
    throw res
  } else {
    if ([401, 403, 500].includes(res.status)) {
      shouldLog && console.log('api-wrapper::server-error(custom)')
      throw new Error(res.status)
    }
  }

  if (operationOptions.isDownload) {
    promptForDownload(
      res,
      operationOptions.defaultDownloadFilename || '',
      operationOptions.forceDefaultDownloaFilename
    )
  }

  let responseBody = res.data || {}

  let apiHeaders = {}
  Object.keys(responseBody)
    .filter((k) => k !== 'data')
    .forEach((k) => (apiHeaders[k] = responseBody[k]))
  res.apiHeaders = apiHeaders

  //Server errors are output to console in any env
  try {
    if (responseBody.error) {
      console.error(
        `api-wrapper::server-error: ${JSON.stringify(responseBody.error)}`
      )
    }
  } catch (err) {
    console.log('api-wrapper::server-error')
    console.error(responseBody.error)
  }

  if (responseBody.data) {
    res.data = responseBody.data
  }

  let keys = Object.keys(res.data)

  res.isArray = res.data instanceof Array

  if (typeof res.data.items !== 'undefined') {
    res.isArray = res.data.items instanceof Array
    res.data = res.data.items || []
  }

  if (!res.isArray) {
    res.data = res.data || {}
  }

  if (res?.data?.error instanceof Array && res?.data?.error.length > 0) {
    console.warn('api-wrapper::server-error', res)
    if ((res.data.error[0].message || '').includes("Vous n'avez accès")) {
      bus.$emit('403')
      res.data = []
    }
  }

  try {
    delete requestOptions.headers
  } catch (err) {
    //
  }
  shouldLog &&
    console.log(
      'api_response',
      //`request:`,
      //requestOptions,
      `url`,
      requestOptions.url,
      res.data.length ? `(${res.data.length} items)` : '(object)',
      {
        data:
          res.data instanceof Array
            ? (res.data.length > 0 && `Keys: ${Object.keys(res.data[0])}`) || []
            : `Keys are ` + keys.join(', '),
      }
    )

  if (operationOptions.populate && res.data instanceof Array && methods) {
    await populateApiPlatformResponse(res, operationOptions.populate, methods)
  }

  try {
    await setCacheFromAPIResponse(
      requestOptions.relativeUrl,
      res,
      operationOptions
    )
  } catch (err) {
    console.warn('API-WRAPPER: Fail to cache response')
    errorLogger.logError(err)
  }

  return res
}

/**
 * @warn unused feature
 * Used by Diagnostic module to populate sensor assigments with sensors details (i.g To Match ANA1 to ANA1 sensor assigments item)
 * @param {Object} res API Wrapper response {data:[]}
 * @param {Array} populateOptions i.g ['associatedSensor'] or {items:['associatedSensor']}
 */
export async function populateApiPlatformResponse(
  res,
  populateOptions,
  methods
) {
  let populateItems = []
  if (populateOptions instanceof Array && populateOptions.items === undefined) {
    populateItems = populateOptions
  } else {
    populateItems = populateOptions.items
  }

  let dataArray = populateOptions.arrayHandler
    ? populateOptions.arrayHandler(res.data)
    : res.data

  console.log('api::populate', {
    populateItems,
    dataArray,
    populateOptions,
  })
  for (let populateItem of populateItems) {
    for (
      let resItemIndex = 0;
      resItemIndex < dataArray.length;
      resItemIndex++
    ) {
      let resItem = dataArray[resItemIndex]
      if (resItem[populateItem] && resItem[populateItem].charAt(0) === '/') {
        console.log('api::populate', populateItem)

        if (populateOptions.populateHandler) {
          dataArray[resItemIndex][populateItem] =
            populateOptions.populateHandler(resItem[populateItem])
          if (dataArray[resItemIndex][populateItem] instanceof Promise) {
            dataArray[resItemIndex][populateItem] = await dataArray[
              resItemIndex
            ][populateItem]
          }
        } else {
          dataArray[resItemIndex][populateItem] = (
            await methods.get(resItem[populateItem])
          ).data
        }
        console.log(
          'api::populate::success',
          dataArray[resItemIndex][populateItem]
        )
      } else {
        console.log('api::populate::skip', resItem)
      }
    }
  }
}

/**
 *
 * @param {String} options.uri  i.g /geored/client/hierarchies?page=1&itemsPerPage=100
 * @param {Object} options.payload Used for querystring parameters {query:'foo'} will add ?query=foo
 * @param {Boolean} options.forcePayload Payload will be also used in pagination calls (Intermediate requests)
 * @param {function} options.callback To be called for each response
 * @returns
 */
export function getAPIV3Pooling(options = {}) {
  let callback =
    options.callback ||
    ((data) => {
      //console.log(`getAPIV3Pooling::callback`, { data })
    })
  return new Promise((resolve, reject) => {
    let items = []
    async function getAPIV3PoolingHandler(uri, isPagination = false) {
      let querystringPayload = isPagination ? {} : options.payload || {}
      if (options.forcePayload) {
        querystringPayload = {
          ...querystringPayload,
          ...options.payload,
        }
      }
      let data = (
        await api.v3.get(uri, querystringPayload, {
          headers: {
            Accept: 'application/hal+json',
          },
          ...(options.fetchOptions || {}),
        })
      ).data
      let newItems = getNestedValue(data, '_embedded.item', [])
      if (options.transform) {
        newItems = newItems.map(options.transform)
      }
      items = items.concat(newItems)
      let shouldAbort = callback(newItems) === false
      let isLastPage =
        !data?._links?.next?.href ||
        (data?._links?.self?.href || '') == (data?._links?.last?.href || '')

      shouldLog &&
        console.log('getAPIV3Pooling', {
          isLastPage,
          shouldAbort,
        })

      if (isLastPage || shouldAbort) {
        shouldLog &&
          console.log('getAPIV3Pooling:resolved', {
            items,
          })
        resolve(items)
      } else {
        getAPIV3PoolingHandler(data._links.next.href, true).catch(reject)
      }
    }
    getAPIV3PoolingHandler(options.uri).catch(reject)
  })
}