MetaPhase-Consulting/State-TalentMAP

View on GitHub
src/api.js

Summary

Maintainability
A
0 mins
Test Coverage
C
78%
import axios from 'axios';
import { setupCache } from 'axios-cache-adapter';
import localforage from 'localforage';
import memoize from 'memoize-one';
import { get, throttle } from 'lodash';
import Enum from 'enum';
import bowser from 'bowser';
import { hoursToMilliseconds } from 'date-fns-v2';
import { setUserEmpId } from 'actions/userProfile';
import { toastWarning } from 'actions/toast';
import { fetchJWT, fetchUserToken, hasValidToken, isOnProxy, propOrDefault, redirectToLoginRedirect } from 'utilities';
import { checkFlag } from 'flags';
import { staticFilters } from './reducers/filters/filters';
import { logoutRequest } from './login/actions';

const version = process.env.VERSION;

// Headers that can be set to denote a certain interceptor to be performed
export const INTERCEPTORS = new Enum({ PUT_PERDET: 'AXIOS_ONLY_PUT_PERDET' });

const interceptorCounts = {
  [INTERCEPTORS.PUT_PERDET.value]: 0,
};

const browser = bowser.getParser(window.navigator.userAgent);
const isIE = browser.satisfies({ 'internet explorer': '<=11' });

let sessionExpired = false;
let hasShownAlert = false;
setTimeout(() => {
  sessionExpired = true;
}, hoursToMilliseconds(1));

// Make sure the user isn't spammed with redirects
const debouncedLogout = throttle(
  // eslint-disable-next-line global-require
  () => require('./store').store.dispatch(logoutRequest()),
  2000,
  { leading: true, trailing: false },
);

// eslint-disable-next-line no-unused-vars
const debouncedNetworkAlert = throttle(
  // eslint-disable-next-line global-require
  () => require('./store').store.dispatch(toastWarning(
    "We're having trouble reaching our servers. Some information on TalentMAP may not display correctly.",
    'Server Error',
    'network-error',
    true,
    { position: 'bottom-center', autoClose: 6500 },
  )),
  10000,
  { leading: true, trailing: false },
);

// eslint-disable-next-line no-unused-vars
const debouncedExpiredSessionAlert = throttle(
  // eslint-disable-next-line global-require
  () => require('./store').store.dispatch(toastWarning(
    'Your Go Browser session may have expired. Try refreshing the page if you are encountering issues.',
    'Network Session',
    'network-error',
    true,
    { position: 'bottom-center', autoClose: false },
  )),
  10000,
  { leading: true, trailing: false },
);

// Request paths that we want cached. In case they fail, an older response can be used.
const pathsToCache = [
  ...staticFilters.filters.filter(f => f.item.tryCache).map(m => m.item.endpoint)
    .filter(f => f),
  // could add other paths here...
];

// Create localforage instance
const localforageStore = localforage.createInstance({
  // List of drivers used. Use IndexedDB.
  driver: [
    localforage.INDEXEDDB,
  ],
  // Prefix all storage keys to prevent conflicts.
  // Base this on the app version to prevent data structure conflicts.
  name: `talentmap-api-cache-${version}`,
});

// Create `axios-cache-adapter` instance
const cache = setupCache({
  maxAge: 1,
  store: localforageStore,
  readOnError: (error) => {
    const err = get(error, 'response.status');
    if (err === 401) { // ingore 401s (unauthenticated)
      return false;
    }
    return true;
  },
  // Deactivate `clearOnStale` option so that we can actually read stale cache data
  clearOnStale: false,
  // {Boolean} Clear all cache when a cache write error occurs
  // (prevents size quota problems in `localStorage`).
  clearOnError: true,
  exclude: {
    // {Boolean} Exclude requests with query parameters.
    query: false,
    // {Function} Method which returns a `Boolean` to determine if request
    // should be excluded from cache.
    filter: (r) => {
      const paths$ = pathsToCache || [];
      const url = get(r, 'url');
      const includesValue = paths$.some(value => url.indexOf(value) !== -1);
      if (includesValue) {
        return false;
      }
      return true;
    },
  },
});

export const config = () => ({
  // use API_URL by default, but can be overriden from within api_config flag if exists
  baseURL: process.env.API_URL || 'http://localhost:8000/api/v1',
  adapter: !isIE ? cache.adapter : undefined, // axios-cache-adapter does not work in IE11
  ...(checkFlag('api_config') || {}),
});

// Called as a function so that sessionStorage can be set first
const api = () => {
  const api$ = axios.create(config());

  api$.interceptors.request.use((request) => {
    const requestWithAuth = request;
    if (hasValidToken()) {
      requestWithAuth.headers.Authorization = fetchUserToken();
    }

    return requestWithAuth;
  });

  // Add JWT
  api$.interceptors.request.use((request) => {
    const requestWithJwt = request;
    const jwt = fetchJWT();
    if (jwt) {
      requestWithJwt.headers.jwt = jwt;
    }

    return requestWithJwt;
  });

  // Call the /perdet_seq_num endpoint if the required header is there
  api$.interceptors.request.use((request) => {
    const header = INTERCEPTORS.PUT_PERDET.value;
    if (get(request, `headers.${header}`)) {
      // clone the request
      const request$ = { ...request };
      // delete the header as we don't want to send it to the server
      delete request$.headers[INTERCEPTORS.PUT_PERDET.value];
      // only perform the additional action if count === 0
      if (get(interceptorCounts, header, 0) === 0) {
        return setUserEmpId()
          .then(() => {
            // increment the counter
            interceptorCounts[header] += 1;
            return Promise.resolve(request$);
          })
          .catch(() => Promise.resolve(request$));
      }
      // otherwise return the request with the header removed
      return request$;
    }
    // otherwise return the original request
    return request;
  });

  api$.interceptors.response.use(response => response, (error) => {
    // We want to perform this on 302 to Microsoft, but CORS blocks visibility from axios,
    // so this is the closest we can get to capturing the session expiring.
    // https://github.com/axios/axios/issues/838#issuecomment-304033403
    if (typeof error.response === 'undefined' && isOnProxy() && sessionExpired && !hasShownAlert) {
      debouncedExpiredSessionAlert();
      hasShownAlert = true;
    }

    switch (propOrDefault(error, 'response.status')) {
      case 401: {
      // Due to timing of import store before history is created, importing store here causes
      // exports of api to be undefined. So this causes an error for `userProfile.js` when
      // attempting to login. Went with the eslint quick re-enable to get around this.
        debouncedLogout();
        break;
      }

      /* case 500: {
        debouncedNetworkAlert();
        break;
      } */

      default: {
      // We don't want to stop the pipeline even if there's a problem with the dispatch
      // and if there is, that should be resolved. This is just a placeholder until we
      // actually need a default case to satisfy eslint.
        const serverMessage = propOrDefault(error, 'response.data.detail');

        if (serverMessage === 'Invalid token') {
          redirectToLoginRedirect();
        }
      }
    }

    return Promise.reject(error);
  });

  return api$;
};

// Memoized, since once sessionStorage is set, this won't change again
const memoizedApi = memoize(api);

export default memoizedApi;