Files
minstrel/web/src/lib/api/client.ts
T

86 lines
3.3 KiB
TypeScript

import { plural } from '#lib/utils/plural.js';
export type ApiError = {
code: string;
message: string;
status: number;
/** Seconds until a 429'd request may be retried, from Retry-After. */
retryAfter?: number;
};
/**
* The sign-in, register and reset screens' wording for a throttled attempt,
* or null when err isn't one. Rounds up to whole minutes: the server's
* windows are minutes long, and "try again in 1 second" after a lockout
* would be untrue the moment it was read.
*/
export function rateLimitMessage(err: unknown): string | null {
const apiErr = err as ApiError | undefined;
if (apiErr?.status !== 429) return null;
const secs = apiErr.retryAfter;
if (!secs || secs <= 0) return 'Too many attempts. Try again in a few minutes.';
const mins = Math.ceil(secs / 60);
return `Too many attempts. Try again in ${plural(mins, 'minute', 'minutes')}.`;
}
export type User = {
id: string;
username: string;
is_admin: boolean;
};
export type LoginResponse = {
token: string;
user: User;
};
export async function apiFetch(path: string, init?: RequestInit): Promise<unknown> {
const res = await fetch(path, {
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', ...(init?.headers ?? {}) },
...init
});
const body = res.status === 204 ? null : await res.json().catch(() => null);
if (!res.ok) {
if (res.status === 401) {
// Lazy import: auth/store imports from this file, so a top-level
// import would be circular. By the time any 401 actually happens
// at runtime, both modules have finished loading.
const { logout } = await import('#lib/auth/store.svelte.js');
await logout({ silent: true });
}
// Two error envelope shapes ship in this codebase today:
// 1) {"error": {"code": "...", "message": "..."}} — non-admin endpoints
// 2) {"error": "code_string"} — admin endpoints
// Without handling (2), every admin error toast renders 'unknown'.
const errorField = body && (body as { error?: unknown }).error;
let code = 'unknown';
let message = res.statusText;
if (typeof errorField === 'string') {
code = errorField;
} else if (errorField && typeof errorField === 'object') {
const env = errorField as { code?: string; message?: string };
code = env.code ?? 'unknown';
message = env.message ?? res.statusText;
}
const err: ApiError = { code, message, status: res.status };
if (res.status === 429) {
const retryAfter = Number(res.headers.get('Retry-After'));
if (Number.isFinite(retryAfter) && retryAfter > 0) err.retryAfter = retryAfter;
}
throw err;
}
return body;
}
export const api = {
get: <T>(path: string): Promise<T> => apiFetch(path) as Promise<T>,
post: <T>(path: string, body: unknown): Promise<T> =>
apiFetch(path, { method: 'POST', body: JSON.stringify(body) }) as Promise<T>,
put: <T>(path: string, body: unknown): Promise<T> =>
apiFetch(path, { method: 'PUT', body: JSON.stringify(body) }) as Promise<T>,
patch: <T>(path: string, body: unknown): Promise<T> =>
apiFetch(path, { method: 'PATCH', body: JSON.stringify(body) }) as Promise<T>,
del: <T = null>(path: string): Promise<T> =>
apiFetch(path, { method: 'DELETE' }) as Promise<T>
};