Fetch API wrapper to be used in interviews

import { clearTimeout } from "node:timers";

interface FetchApiOptions extends Omit<RequestInit, "body"> {
  queryParams?: Record<string, any>;
  timeout?: number;
  body?: any;
  responseType?: "json" | "text" | "blob" | "raw";
}

interface CacheEntry {
  data: any;
  expiration: number;
}

async function fetchApi(
  url: string,
  {
    method = "GET",
    responseType = "json",
    headers = {},
    body = null,
    timeout = 0,
    queryParams = {},
    ...otherOptions
  }: FetchApiOptions = {},
): Promise<any> {
  // Just fetching the queryParams here and adding it in the URL
  if (queryParams && Object.keys(queryParams).length > 0) {
    const params = new URLSearchParams(queryParams);
    url += (url.includes("?") ? "&" : "?") + params.toString();
  }

  //Creating AbortController for timeout support
  const controller = new AbortController();
  let timeoutId: ReturnType<typeof setTimeout> | undefined;
  if (timeout > 0) {
    timeoutId = setTimeout(() => controller.abort(), timeout);
  }

  // Handling request body
  let reqBody: BodyInit | null = body;
  if (body && typeof body === "object" && !(body instanceof FormData)) {
    reqBody = JSON.stringify(body);

    headers = {
      ...headers,
      "Content-Type":
        (headers as Record<string, string>)["Content-Type"] ||
        "application/json",
    };
  }

  const fetchOptions: RequestInit = {
    method,
    headers,
    body: reqBody,
    signal: controller.signal,
    ...otherOptions,
  };

  try {
    const response = await fetch(url, fetchOptions);

    // We need to clear any pending timeouts once we know that the API failed
    if (timeoutId) clearTimeout(timeoutId);

    if (!response.ok) throw new Error(`HTTP error! ,${response.status}`);

    switch (responseType) {
      case "json":
        return await response.json();
      case "text":
        return await response.text();
      case "blob":
        return await response.blob();
      default:
        return response;
    }
  } catch (error) {
    // We need to clear any pending timeouts once we know the API failed
    if (timeoutId) clearTimeout(timeoutId);
    console.log("Error occurred", error);
  }
}

const cache = new Map<string, CacheEntry>();
export default function cachedApiCall(responseCacheTime: number) {
  return async function call(url: string, options: FetchApiOptions = {}) {
    const key = JSON.stringify({ url, options });
    const now = Date.now();
    if (cache.has(key)) {
      const entry = cache.get(key);
      if (cache.get(key)!.expiration > now) {
        console.log("Returning cached response", key);
        return entry?.data;
      } else {
        cache.delete(key);
      }
    }

    const data = await fetchApi(url, options);

    cache.set(key, { data, expiration: now + responseCacheTime });

    return data;
  };
}

async function main() {
  const callApi = cachedApiCall(5000); // Cache lifetime of 5000ms (5 seconds)

  try {
    const result1 = await callApi(
      "https://jsonplaceholder.typicode.com/posts/1",
      {
        method: "GET",
        timeout: 5000,
        responseType: "json",
      },
    );
    console.log("First call result:", result1);
  } catch (error) {
    console.log("Error occured", error);
  }

  try {
    const result2 = await callApi(
      "https://jsonplaceholder.typicode.com/posts/1",
      {
        method: "GET",
        timeout: 5000,
        responseType: "json",
      },
    );

    console.log("From cache", result2);
  } catch (error) {
    console.log("error occured", error);
  }
}

main();