Files
saas_frontend/src/lib/apiClient.ts
T

169 lines
4.3 KiB
TypeScript

import { toast } from "react-toastify";
import { API_BASE_URL } from "../constant";
import { clearAuthCookies, refreshLoggedInFlag } from "./authCookies";
type ApiRequestOptions = Omit<RequestInit, "body"> & {
body?: unknown;
toast?: boolean;
successMessage?: string;
errorMessage?: string;
silent?: boolean;
};
let isRefreshing = false;
let refreshQueue: (() => void)[] = [];
const API_TOAST_OPTIONS = { autoClose: 2000 };
const hardLogout = () => {
clearAuthCookies();
toast.error(
"Your session has expired. Please sign in again.",
API_TOAST_OPTIONS
);
window.location.href = "/signin";
};
const refreshAccessToken = async () => {
const res = await fetch(`${API_BASE_URL}/api/auth/refresh`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
if (!res.ok) throw new Error("Refresh failed");
};
const waitForRefresh = (): Promise<void> =>
new Promise<void>((resolve) => refreshQueue.push(resolve));
const doRefresh = async () => {
if (isRefreshing) {
await waitForRefresh();
return;
}
isRefreshing = true;
try {
await refreshAccessToken();
refreshLoggedInFlag();
} finally {
isRefreshing = false;
refreshQueue.forEach((cb) => cb());
refreshQueue = [];
}
};
const request = async <T>(
path: string,
options: ApiRequestOptions = {},
_isRetry = false
): Promise<T> => {
const {
body,
headers,
toast: shouldToast = true,
successMessage,
errorMessage,
silent = false,
...rest
} = options;
const showToast = shouldToast && !silent;
const requestHeaders = new Headers(headers);
let requestBody: BodyInit | undefined;
if (body !== undefined) {
if (body instanceof FormData) {
requestBody = body;
} else {
requestHeaders.set("Content-Type", "application/json");
requestBody = JSON.stringify(body);
}
}
const url = path.startsWith("http")
? path
: `${API_BASE_URL.replace(/\/$/, "")}${path.startsWith("/") ? path : `/${path}`
}`;
const response = await fetch(url, {
...rest,
headers: requestHeaders,
credentials: "include",
body: requestBody,
});
if (response.status === 401 && !url.includes("/api/auth/signin")) {
if (!_isRetry) {
try {
await doRefresh();
return request<T>(path, options, true);
} catch {
hardLogout();
throw new Error("Session expired");
}
}
hardLogout();
throw new Error("Unauthorized");
}
const contentType = response.headers.get("content-type") || "";
const data =
response.status === 204
? null
: contentType.includes("application/json")
? await response.json()
: await response.text();
if (!response.ok) {
const backendError =
typeof data === "object" && data !== null
? (data as any).detail || (data as any).message
: undefined;
const finalErrorMessage = backendError || errorMessage || "Request failed";
if (showToast) {
toast.error(finalErrorMessage, API_TOAST_OPTIONS);
}
throw new Error(finalErrorMessage);
}
const method = String(rest.method || "GET").toUpperCase();
if (showToast && method !== "GET") {
const defaultSuccess =
method === "POST"
? "Created successfully"
: method === "PUT" || method === "PATCH"
? "Updated successfully"
: method === "DELETE"
? "Deleted successfully"
: "Action successful";
const msg =
(successMessage as string) ?? (data?.message as string) ?? defaultSuccess;
toast.success(msg, API_TOAST_OPTIONS);
}
return data as T;
};
export const apiClient = {
get: <T>(path: string, options?: ApiRequestOptions) =>
request<T>(path, { ...options, method: "GET" }),
post: <T>(path: string, body?: unknown, options?: ApiRequestOptions) =>
request<T>(path, { ...options, method: "POST", body }),
put: <T>(path: string, body?: unknown, options?: ApiRequestOptions) =>
request<T>(path, { ...options, method: "PUT", body }),
patch: <T>(path: string, body?: unknown, options?: ApiRequestOptions) =>
request<T>(path, { ...options, method: "PATCH", body }),
delete: <T>(path: string, options?: ApiRequestOptions) =>
request<T>(path, { ...options, method: "DELETE" }),
};