export class AuthError extends Error {
override name = 'AuthError'
constructor(message: string) {
super(message)
}
}
export class APIError extends Error {
override name = 'APIError'
status: number
code: string
constructor(message: string, status: number, code: string) {
super(message)
this.status = status
this.code = code
}
}
export async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
const resp = await fetch(path, {
...init,
headers: {
Accept: 'application/json',
...init?.headers,
},
})
if (resp.status === 401) {
throw new AuthError('not authenticated')
}
if (!resp.ok) {
const ct = resp.headers.get('Content-Type') ?? ''
if (ct.includes('application/problem+json')) {
const body = await resp.json() as { detail?: string; title?: string; status?: number; code?: string }
throw new APIError(body.detail ?? body.title ?? 'error', body.status ?? resp.status, body.code ?? '')
}
if (resp.status >= 500) {
throw new APIError('server error', resp.status, '')
}
throw new APIError(`request failed: ${resp.status}`, resp.status, '')
}
return resp.json() as Promise<T>
}
/**
* apiFetchVoid is a variant of apiFetch for endpoints that return no body
* (e.g. DELETE → 204 No Content). It shares the same auth and error handling
* as apiFetch but does not attempt to parse the response body.
*/
export async function apiFetchVoid(path: string, init?: RequestInit): Promise<void> {
const resp = await fetch(path, {
...init,
headers: {
Accept: 'application/json',
...init?.headers,
},
})
if (resp.status === 401) {
throw new AuthError('not authenticated')
}
if (!resp.ok) {
const ct = resp.headers.get('Content-Type') ?? ''
if (ct.includes('application/problem+json')) {
const body = await resp.json() as { detail?: string; title?: string; status?: number; code?: string }
throw new APIError(body.detail ?? body.title ?? 'error', body.status ?? resp.status, body.code ?? '')
}
if (resp.status >= 500) {
throw new APIError('server error', resp.status, '')
}
throw new APIError(`request failed: ${resp.status}`, resp.status, '')
}
}