All files / client/src/utils api.ts

95% Statements 38/40
100% Branches 12/12
100% Functions 6/6
95% Lines 38/40

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143                                                      609x   609x   609x 71x   71x         71x     609x         3728x   3728x       3728x 3728x 2903x       3728x 3728x           59x     3647x           3647x   25x 25x       25x   25x     3622x 16x 12x     4x   4x         4x     3606x 3606x 3602x               609x           976x               2740x 2740x 530x 530x   2740x               12x      
import { config } from "shared/config";
import {
  ApiDevEndpoint,
  ApiEndpoint,
  AuthEndpoint,
} from "shared/constants/apiEndpoints";
import { ApiError } from "shared/types/api/errors";
import { fetchWithTimeout } from "client/utils/fetchWithTimeout";
import { getJWT } from "client/utils/getJWT";
import {
  isBackgroundRequest,
  onRequestFailure,
  onRequestSuccess,
  shouldTreatHttpErrorAsNetworkError,
  showApiError,
} from "client/utils/networkErrorPolicy";
 
enum HttpMethod {
  GET = "GET",
  POST = "POST",
  DELETE = "DELETE",
}
 
interface ApiResponse<T> {
  data: T;
}
 
const baseURL = config.client().apiServerUrl;
 
const REQUEST_TIMEOUT_MS = 60000;
 
const networkErrorResponse = <T>(background: boolean): ApiResponse<T> => {
  onRequestFailure(background);
 
  const error: ApiError = {
    errorId: "unknown",
    message: "Network error",
    status: "error",
  };
  return { data: error as T };
};
 
const apiFetch = async <T>(
  url: string,
  method: HttpMethod,
  body?: unknown,
): Promise<ApiResponse<T>> => {
  const background = isBackgroundRequest(method, url);
 
  const headers: Record<string, string> = {
    "Content-Type": "application/json",
  };
 
  const authToken = getJWT();
  if (authToken) {
    headers.Authorization = `Bearer ${authToken}`;
  }
 
  let response: Response;
  try {
    response = await fetchWithTimeout(`${baseURL}${url}`, REQUEST_TIMEOUT_MS, {
      method,
      headers,
      body: body === undefined ? undefined : JSON.stringify(body),
    });
  } catch {
    return networkErrorResponse<T>(background);
  }
 
  onRequestSuccess();
 
  // Reading the response body can reject with AbortError if the page unloads
  // mid-response, so json() calls need the same error handling as fetch()
 
  // A redirect response carries its target in a JSON body
  if ([301, 302].includes(response.status)) {
    let data: { location: string };
    try {
      data = (await response.json()) as { location: string };
    } catch {
      return networkErrorResponse<T>(background);
    }
    location.href = data.location;
    // eslint-disable-next-line @typescript-eslint/no-empty-function
    return new Promise<ApiResponse<T>>(() => {});
  }
 
  if (!response.ok) {
    if (shouldTreatHttpErrorAsNetworkError(background)) {
      return networkErrorResponse<T>(background);
    }
 
    showApiError(method, url, response.status);
 
    const error: ApiError = {
      errorId: "unknown",
      message: "Invalid API response",
      status: "error",
    };
    return { data: error as T };
  }
 
  try {
    const data = (await response.json()) as T;
    return { data };
  } catch {
    return networkErrorResponse<T>(background);
  }
};
 
type Endpoint = ApiEndpoint | ApiDevEndpoint | AuthEndpoint;
 
export const api = {
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters
  post: async <RES = never, REQ = never>(
    url: Endpoint,
    data?: REQ,
  ): Promise<ApiResponse<RES>> => {
    return await apiFetch<RES>(url, HttpMethod.POST, data);
  },
 
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters
  get: async <RES = never, REQ = never>(
    url: Endpoint,
    options?: { params?: REQ },
  ): Promise<ApiResponse<RES>> => {
    let fetchUrl: string = url;
    if (options?.params) {
      const searchParams = new URLSearchParams(options.params);
      fetchUrl = `${url}?${searchParams.toString()}`;
    }
    return await apiFetch<RES>(fetchUrl, HttpMethod.GET);
  },
 
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters
  delete: async <RES = never, REQ = never>(
    url: Endpoint,
    options?: { data?: REQ },
  ): Promise<ApiResponse<RES>> => {
    return await apiFetch<RES>(url, HttpMethod.DELETE, options?.data);
  },
};