export interface ApiErrorShape {
error: string;
message: string;
details?: unknown;
}
export class ApiError extends Error {
readonly status: number;
readonly code: string;
readonly details?: unknown;
constructor(status: number, body: ApiErrorShape) {
super(body.message);
this.status = status;
this.code = body.error;
this.details = body.details;
}
}
interface RequestOptions
{
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE';
body?: TBody;
signal?: AbortSignal;
formData?: FormData;
}
async function request(
path: string,
options: RequestOptions = {},
): Promise {
const headers: Record = {};
let body: BodyInit | undefined;
if (options.formData) {
body = options.formData;
} else if (options.body !== undefined) {
headers['content-type'] = 'application/json';
body = JSON.stringify(options.body);
}
const response = await fetch(path, {
method: options.method ?? 'GET',
headers,
body,
credentials: 'include',
signal: options.signal,
});
const isJson = response.headers.get('content-type')?.includes('application/json');
const payload = isJson ? await response.json().catch(() => null) : null;
if (!response.ok) {
const errBody: ApiErrorShape =
payload && typeof payload === 'object' && 'error' in payload
? (payload as ApiErrorShape)
: { error: 'HTTP', message: response.statusText || 'Request failed' };
throw new ApiError(response.status, errBody);
}
return payload as TResponse;
}
export const api = {
get: (path: string, signal?: AbortSignal) => request(path, { signal }),
post: (path: string, body?: B) => request(path, { method: 'POST', body }),
patch: (path: string, body?: B) =>
request(path, { method: 'PATCH', body }),
delete: (path: string) => request(path, { method: 'DELETE' }),
upload: (path: string, formData: FormData) =>
request(path, { method: 'POST', formData }),
};