Files
Cibello-app/apps/mobile/src/lib/api.ts
T
2026-08-05 19:21:11 +07:00

109 lines
3.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 };