fooder-app/src/lib/api/client.ts
2026-04-01 23:51:16 +02:00

102 lines
2.8 KiB
TypeScript

import { auth } from '$lib/auth/store.svelte';
import { goto } from '$app/navigation';
const BASE = '/api';
let isRefreshing = false;
let refreshPromise: Promise<string | null> | null = null;
async function doRefresh(): Promise<string | null> {
const refreshToken = auth.getRefreshToken();
if (!refreshToken) return null;
try {
const res = await fetch(`${BASE}/token/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh_token: refreshToken })
});
if (!res.ok) return null;
const data = await res.json();
auth.setTokens(data.access_token, data.refresh_token);
return data.access_token;
} catch {
return null;
}
}
async function getValidToken(): Promise<string | null> {
if (auth.accessToken) return auth.accessToken;
// Deduplicate concurrent refresh calls
if (isRefreshing) return refreshPromise;
isRefreshing = true;
refreshPromise = doRefresh().finally(() => {
isRefreshing = false;
refreshPromise = null;
});
return refreshPromise;
}
export async function apiFetch(
path: string,
options: RequestInit = {},
retry = true
): Promise<Response> {
const token = await getValidToken();
const headers = new Headers(options.headers);
if (token) headers.set('Authorization', `Bearer ${token}`);
const res = await fetch(`${BASE}${path}`, { ...options, headers });
if (res.status === 401 && retry) {
// Force a refresh and retry once
auth.setAccessToken(''); // clear so getValidToken triggers refresh
const newToken = await doRefresh();
if (!newToken) {
auth.clear();
goto('/login');
return res;
}
auth.setAccessToken(newToken);
headers.set('Authorization', `Bearer ${newToken}`);
return fetch(`${BASE}${path}`, { ...options, headers });
}
return res;
}
export async function apiGet<T>(path: string): Promise<T> {
const res = await apiFetch(path);
if (!res.ok) throw new Error(`GET ${path} failed: ${res.status}`);
return res.json();
}
export async function apiPost<T>(path: string, body: unknown): Promise<T> {
const res = await apiFetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw Object.assign(new Error(`POST ${path} failed: ${res.status}`), { status: res.status, detail: err });
}
return res.json();
}
export async function apiPatch<T>(path: string, body: unknown): Promise<T> {
const res = await apiFetch(path, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!res.ok) throw new Error(`PATCH ${path} failed: ${res.status}`);
return res.json();
}
export async function apiDelete(path: string): Promise<void> {
const res = await apiFetch(path, { method: 'DELETE' });
if (!res.ok) throw new Error(`DELETE ${path} failed: ${res.status}`);
}