43 lines
1.5 KiB
TypeScript
43 lines
1.5 KiB
TypeScript
|
|
// Fired when any API call returns 401 (session expired/absent). App registers it
|
||
|
|
// to drop to the login screen.
|
||
|
|
let onUnauthorized: (() => void) | null = null;
|
||
|
|
export function setUnauthorizedHandler(fn: (() => void) | null): void {
|
||
|
|
onUnauthorized = fn;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface ApiOptions extends RequestInit {
|
||
|
|
// When true, a 401 body is returned to the caller instead of throwing +
|
||
|
|
// firing the logout hook. Used by /login to surface { success, error }.
|
||
|
|
allowAuthErrorBody?: boolean;
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function apiFetch<T>(url: string, options?: ApiOptions): Promise<T> {
|
||
|
|
const { allowAuthErrorBody, ...init } = options ?? {};
|
||
|
|
const res = await fetch(url, init);
|
||
|
|
if (!res.ok) {
|
||
|
|
if (res.status === 401) {
|
||
|
|
if (!allowAuthErrorBody) { onUnauthorized?.(); throw new Error('unauthenticated'); }
|
||
|
|
// else fall through and return the { success, error } body
|
||
|
|
} else if (res.status === 400 || res.status === 409) {
|
||
|
|
// Validation / conflict responses carry a { error, message } body the caller reads.
|
||
|
|
} else {
|
||
|
|
const text = await res.text().catch(() => res.statusText);
|
||
|
|
throw new Error(text || `HTTP ${res.status}`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return res.json() as Promise<T>;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function apiGet<T>(url: string): Promise<T> {
|
||
|
|
return apiFetch<T>(url);
|
||
|
|
}
|
||
|
|
|
||
|
|
export function apiPost<T>(url: string, body: unknown, options?: ApiOptions): Promise<T> {
|
||
|
|
return apiFetch<T>(url, {
|
||
|
|
method: 'POST',
|
||
|
|
headers: { 'Content-Type': 'application/json' },
|
||
|
|
body: JSON.stringify(body),
|
||
|
|
...options,
|
||
|
|
});
|
||
|
|
}
|