This commit is contained in:
112
frontend/src/lib/api.ts
Normal file
112
frontend/src/lib/api.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
details?: unknown;
|
||||
|
||||
constructor(message: string, status: number, details?: unknown) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
this.status = status;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
export interface FetchJsonOptions extends Omit<RequestInit, 'body'> {
|
||||
body?: unknown;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
const API_BASE = import.meta.env.PUBLIC_API_URL || 'http://localhost:8001/api/';
|
||||
|
||||
function buildUrl(endpoint: string): string {
|
||||
return new URL(endpoint.replace(/^\/+/, ''), API_BASE).toString();
|
||||
}
|
||||
|
||||
function normalizeHeaders(headers: HeadersInit | undefined): Record<string, string> {
|
||||
if (!headers) return {};
|
||||
if (headers instanceof Headers) {
|
||||
return Object.fromEntries(headers.entries());
|
||||
}
|
||||
if (Array.isArray(headers)) {
|
||||
return Object.fromEntries(headers);
|
||||
}
|
||||
return { ...headers };
|
||||
}
|
||||
|
||||
function createTimeoutSignal(signal: AbortSignal | null | undefined, timeoutMs: number): {
|
||||
signal: AbortSignal;
|
||||
clear: () => void;
|
||||
} {
|
||||
const timeoutController = new AbortController();
|
||||
const timer = setTimeout(() => timeoutController.abort(), timeoutMs);
|
||||
|
||||
const clear = () => {
|
||||
clearTimeout(timer);
|
||||
};
|
||||
|
||||
if (signal) {
|
||||
signal.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
clear();
|
||||
timeoutController.abort();
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
}
|
||||
|
||||
return { signal: timeoutController.signal, clear };
|
||||
}
|
||||
|
||||
export async function fetchJson<T>(endpoint: string, options: FetchJsonOptions = {}): Promise<T> {
|
||||
const {
|
||||
body,
|
||||
headers,
|
||||
timeoutMs = 10000,
|
||||
signal,
|
||||
...rest
|
||||
} = options;
|
||||
|
||||
const requestHeaders: Record<string, string> = {
|
||||
Accept: 'application/json',
|
||||
...normalizeHeaders(headers),
|
||||
};
|
||||
|
||||
let requestBody: BodyInit | undefined;
|
||||
if (body !== undefined && body !== null) {
|
||||
requestHeaders['Content-Type'] = 'application/json';
|
||||
requestBody = JSON.stringify(body);
|
||||
}
|
||||
|
||||
const timeout = createTimeoutSignal(signal, timeoutMs);
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(buildUrl(endpoint), {
|
||||
...rest,
|
||||
headers: requestHeaders,
|
||||
body: requestBody,
|
||||
signal: timeout.signal,
|
||||
});
|
||||
} finally {
|
||||
timeout.clear();
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
let details: unknown = null;
|
||||
try {
|
||||
details = await response.json();
|
||||
} catch {
|
||||
details = await response.text().catch(() => null);
|
||||
}
|
||||
const message =
|
||||
typeof details === 'object' && details && 'detail' in details
|
||||
? String((details as { detail: unknown }).detail)
|
||||
: `API request failed (${response.status})`;
|
||||
throw new ApiError(message, response.status, details);
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return null as T;
|
||||
}
|
||||
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
Reference in New Issue
Block a user