109 lines
3.2 KiB
TypeScript
109 lines
3.2 KiB
TypeScript
import Constants from "expo-constants";
|
||
import { useAuth } from "./auth";
|
||
|
||
/**
|
||
* API-klient. Mobilappen pratar ENDAST med Food API – aldrig direkt med
|
||
* modelleverantörer, och innehåller inga AI-hemligheter (spec §31, §61.13).
|
||
* 401 → automatisk refresh med roterande token → retry en gång.
|
||
*/
|
||
|
||
const API_BASE =
|
||
(Constants.expoConfig?.extra as { apiBaseUrl?: string } | undefined)?.apiBaseUrl ??
|
||
"http://localhost:4000";
|
||
|
||
export class ApiError extends Error {
|
||
constructor(
|
||
public readonly status: number,
|
||
public readonly code: string,
|
||
message: string,
|
||
public readonly details?: unknown,
|
||
) {
|
||
super(message);
|
||
}
|
||
}
|
||
|
||
let refreshPromise: Promise<boolean> | null = null;
|
||
|
||
async function tryRefresh(): Promise<boolean> {
|
||
if (!refreshPromise) {
|
||
refreshPromise = (async () => {
|
||
const { getRefreshToken, setSession, logout } = useAuth.getState();
|
||
const refreshToken = await getRefreshToken();
|
||
if (!refreshToken) return false;
|
||
try {
|
||
const res = await fetch(`${API_BASE}/v1/auth/refresh`, {
|
||
method: "POST",
|
||
headers: { "content-type": "application/json" },
|
||
body: JSON.stringify({ refreshToken }),
|
||
});
|
||
if (!res.ok) {
|
||
await logout();
|
||
return false;
|
||
}
|
||
const json = (await res.json()) as { accessToken: string; refreshToken: string };
|
||
await setSession(
|
||
{ accessToken: json.accessToken, refreshToken: json.refreshToken },
|
||
useAuth.getState().user,
|
||
);
|
||
return true;
|
||
} catch {
|
||
return false;
|
||
} finally {
|
||
setTimeout(() => {
|
||
refreshPromise = null;
|
||
}, 100);
|
||
}
|
||
})();
|
||
}
|
||
return refreshPromise;
|
||
}
|
||
|
||
export async function api<T = unknown>(
|
||
path: string,
|
||
options: { method?: string; body?: unknown; retry?: boolean } = {},
|
||
): Promise<T> {
|
||
const { accessToken } = useAuth.getState();
|
||
const res = await fetch(`${API_BASE}${path}`, {
|
||
method: options.method ?? "GET",
|
||
headers: {
|
||
"content-type": "application/json",
|
||
...(accessToken ? { authorization: `Bearer ${accessToken}` } : {}),
|
||
},
|
||
body: options.body != null ? JSON.stringify(options.body) : undefined,
|
||
});
|
||
|
||
if (res.status === 401 && options.retry !== false) {
|
||
const refreshed = await tryRefresh();
|
||
if (refreshed) return api<T>(path, { ...options, retry: false });
|
||
}
|
||
|
||
const json = (await res.json().catch(() => ({}))) as {
|
||
error?: { code?: string; message?: string; details?: unknown };
|
||
};
|
||
if (!res.ok) {
|
||
throw new ApiError(
|
||
res.status,
|
||
json.error?.code ?? "UNKNOWN",
|
||
json.error?.message ?? `HTTP ${res.status}`,
|
||
json.error?.details,
|
||
);
|
||
}
|
||
return json as T;
|
||
}
|
||
|
||
/** Ladda upp en bild till en presignad URL (mock-S3 i dev, riktig S3 i prod). */
|
||
export async function uploadImage(
|
||
upload: { uploadUrl: string; headers: Record<string, string> },
|
||
localUri: string,
|
||
): Promise<void> {
|
||
const blob = await (await fetch(localUri)).blob();
|
||
const res = await fetch(upload.uploadUrl, {
|
||
method: "PUT",
|
||
headers: upload.headers,
|
||
body: blob,
|
||
});
|
||
if (!res.ok) throw new ApiError(res.status, "UPLOAD_FAILED", "Bilduppladdningen misslyckades.");
|
||
}
|
||
|
||
export { API_BASE };
|