113 lines
3.0 KiB
TypeScript
113 lines
3.0 KiB
TypeScript
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;
|
|
}
|