portainer/portainer

View on GitHub
app/portainer/services/axios.ts

Summary

Maintainability
A
2 hrs
Test Coverage
import Axios, {
  AxiosError,
  AxiosInstance,
  InternalAxiosRequestConfig,
} from 'axios';
import {
  setupCache,
  buildMemoryStorage,
  CacheAxiosResponse,
  InterpreterResult,
  AxiosCacheInstance,
} from 'axios-cache-interceptor';
import { loadProgressBar } from 'axios-progress-bar';
import 'axios-progress-bar/dist/nprogress.css';

import PortainerError from '@/portainer/error';

import {
  CACHE_DURATION,
  dispatchCacheRefreshEventIfNeeded,
  portainerAgentManagerOperation,
  portainerAgentTargetHeader,
} from './http-request.helper';

const portainerCacheHeader = 'X-Portainer-Cache';

const storage = buildMemoryStorage();
// mock the cache adapter
export const cache = {
  store: {
    clear: () => {
      storage.data = {};
    },
  },
};

function headerInterpreter(
  headers?: CacheAxiosResponse['headers']
): InterpreterResult {
  if (!headers) {
    return 'not enough headers';
  }

  if (headers[portainerCacheHeader]) {
    return CACHE_DURATION;
  }

  return 'not enough headers';
}

const axios = Axios.create({ baseURL: 'api' });
axios.interceptors.request.use((req) => {
  dispatchCacheRefreshEventIfNeeded(req);
  return req;
});

// type guard the axios instance
function isAxiosCacheInstance(
  a: AxiosInstance | AxiosCacheInstance
): a is AxiosCacheInstance {
  return (a as AxiosCacheInstance).defaults.cache !== undefined;
}

// when entering a kubernetes environment, or updating user settings, update the cache adapter
export function updateAxiosAdapter(useCache: boolean) {
  if (useCache) {
    if (isAxiosCacheInstance(axios)) {
      return;
    }

    setupCache(axios, {
      storage,
      ttl: CACHE_DURATION,
      methods: ['get', 'head', 'options', 'post'],
      // cachePredicate determines if the response should be cached based on response
      cachePredicate: {
        containsHeaders: {
          [portainerCacheHeader]: () => true,
        },
        ignoreUrls: [/^(?!.*\bkubernetes\b).*$/gm],
        responseMatch: (res) => {
          if (res.config.method === 'post') {
            if (res.config.url?.includes('selfsubjectaccessreviews')) {
              return true;
            }
            return false;
          }
          return true;
        },
      },
      // headerInterpreter interprets the response headers to determine if the response should be cached
      headerInterpreter,
    });
  }
}

export default axios;

loadProgressBar(undefined, axios);

export const agentTargetHeader = 'X-PortainerAgent-Target';

export function agentInterceptor(config: InternalAxiosRequestConfig) {
  if (!config.url || !config.url.includes('/docker/')) {
    return config;
  }

  const newConfig = { ...config };
  const target = portainerAgentTargetHeader();
  if (target) {
    newConfig.headers[agentTargetHeader] = target;
  }

  if (portainerAgentManagerOperation()) {
    newConfig.headers['X-PortainerAgent-ManagerOperation'] = '1';
  }

  return newConfig;
}

axios.interceptors.request.use(agentInterceptor);

axios.interceptors.response.use(undefined, (error) => {
  if (
    error.response?.status === 401 &&
    !error.config.url.includes('/v2/') &&
    !error.config.url.includes('/api/v4/') &&
    isTransitionRequiresAuthentication()
  ) {
    // eslint-disable-next-line no-console
    console.error('Unauthorized request, logging out');
    window.location.hash = '/logout';
    window.location.reload();
  }

  return Promise.reject(error);
});

const UNAUTHENTICATED_ROUTES = [
  '/logout',
  '/internal-auth',
  '/auth',
  '/init/admin',
];
function isTransitionRequiresAuthentication() {
  return !UNAUTHENTICATED_ROUTES.some((route) =>
    window.location.hash.includes(route)
  );
}

/**
 * Parses an Axios error and returns a PortainerError.
 * @param err The original error.
 * @param msg An optional error message to prepend.
 * @param parseError A function to parse AxiosErrors. Defaults to defaultErrorParser.
 * @returns A PortainerError with the parsed error message and details.
 */
export function parseAxiosError(
  err: unknown,
  msg = '',
  parseError = defaultErrorParser
) {
  let resultErr = err;
  let resultMsg = msg;

  if (isAxiosError(err)) {
    const { error, details } = parseError(err);
    resultErr = error;
    if (msg && details) {
      resultMsg = `${msg}: ${details}`;
    } else {
      resultMsg = msg || details;
    }
  }

  return new PortainerError(resultMsg, resultErr);
}

type DefaultAxiosErrorType = {
  message: string;
  details?: string;
};

export function defaultErrorParser(axiosError: AxiosError<unknown>) {
  if (isDefaultResponse(axiosError.response?.data)) {
    const message = axiosError.response?.data.message || '';
    const details = axiosError.response?.data.details || message;
    const error = new Error(message);
    return { error, details };
  }

  const details = axiosError.response?.data
    ? axiosError.response?.data.toString()
    : '';
  const error = new Error('Axios error');
  return { error, details };
}

export function isDefaultResponse(
  data: unknown
): data is DefaultAxiosErrorType {
  return (
    !!data &&
    typeof data === 'object' &&
    'message' in data &&
    typeof data.message === 'string'
  );
}

export function isAxiosError<ResponseType>(
  error: unknown
): error is AxiosError<ResponseType> {
  return Axios.isAxiosError(error);
}

export function arrayToJson<T>(arr?: Array<T>) {
  if (!arr) {
    return '';
  }

  return JSON.stringify(arr);
}

export function json2formData(json: Record<string, unknown>) {
  const formData = new FormData();

  Object.entries(json).forEach(([key, value]) => {
    if (typeof value === 'undefined' || value === null) {
      return;
    }

    if (value instanceof File) {
      formData.append(key, value);
      return;
    }

    if (Array.isArray(value)) {
      formData.append(key, arrayToJson(value));
      return;
    }

    if (typeof value === 'object') {
      formData.append(key, JSON.stringify(value));
      return;
    }

    formData.append(key, value.toString());
  });

  return formData;
}