fabro/apps/fabro-web/app/lib/api-client.ts
Bryan Helmkamp a1f032e166
refactor(web): move server state to SWR
Replace React Router loader/action state paths with SWR query and mutation hooks.

Add targeted run and board EventSource managers that invalidate SWR keys, and refresh embedded SPA assets.
2026-04-25 07:16:41 -04:00

236 lines
6.6 KiB
TypeScript

export interface ApiOptions {
init?: RequestInit;
request?: Request;
}
export interface PaginatedEnvelope<T> {
data: T[];
meta: { has_more: boolean };
}
export class ApiError extends Error {
readonly status: number;
readonly requestId: string | null;
readonly body: unknown;
constructor({
status,
message,
requestId,
body,
}: {
status: number;
message: string;
requestId: string | null;
body: unknown;
}) {
super(message);
this.name = "ApiError";
this.status = status;
this.requestId = requestId;
this.body = body;
}
}
const API_PREFIX = "/api/v1";
const PAGINATED_API_MAX_PAGES = 50;
const PAGINATED_API_MAX_ITEMS = 5000;
export function apiPath(path: string): string {
return path.startsWith(API_PREFIX) ? path : `${API_PREFIX}${path}`;
}
export function isNotAvailable(status: number): boolean {
return status === 404 || status === 501;
}
export function extractRequestId(body: unknown): string | null {
if (!body || typeof body !== "object") return null;
const record = body as Record<string, unknown>;
if (typeof record.request_id === "string") return record.request_id;
if (typeof record.requestId === "string") return record.requestId;
const errors = record.errors;
if (!Array.isArray(errors) || errors.length === 0) return null;
const first = errors[0];
if (!first || typeof first !== "object") return null;
const error = first as Record<string, unknown>;
if (typeof error.request_id === "string") return error.request_id;
if (typeof error.requestId === "string") return error.requestId;
if (typeof error.detail === "string") {
const match = error.detail.match(/request[_ ]id[=:]?\s*([a-zA-Z0-9-_]+)/i);
if (match) return match[1];
}
return null;
}
function requestIdFromHeaders(headers: Headers): string | null {
return (
headers.get("x-request-id") ??
headers.get("x-fabro-request-id") ??
headers.get("request-id")
);
}
async function parseResponseBody(response: Response): Promise<unknown> {
const contentType = response.headers.get("content-type") ?? "";
const text = await response.text().catch(() => "");
if (!text) return null;
if (contentType.includes("json")) {
try {
return JSON.parse(text);
} catch {
return text;
}
}
try {
return JSON.parse(text);
} catch {
return text;
}
}
async function apiErrorFromResponse(response: Response): Promise<ApiError> {
const body = await parseResponseBody(response);
const requestId = requestIdFromHeaders(response.headers) ?? extractRequestId(body);
return new ApiError({
status: response.status,
message: response.statusText || `HTTP ${response.status}`,
requestId,
body,
});
}
export async function apiRequest(path: string, options?: ApiOptions): Promise<Response> {
const { init, request } = options ?? {};
const response = await fetch(apiPath(path), {
...init,
credentials: "include",
headers: init?.headers,
...(request?.signal ? { signal: request.signal } : {}),
});
if (response.status === 401) {
if (typeof window !== "undefined") {
window.location.href = "/login";
}
throw await apiErrorFromResponse(response);
}
return response;
}
export async function apiFetcher<T>(key: string): Promise<T> {
const response = await apiRequest(key);
if (!response.ok) {
throw await apiErrorFromResponse(response);
}
if (response.status === 204) return undefined as T;
return response.json() as Promise<T>;
}
export async function apiTextFetcher(key: string): Promise<string> {
const response = await apiRequest(key);
if (!response.ok) {
throw await apiErrorFromResponse(response);
}
return response.text();
}
export async function apiNullableFetcher<T>(key: string): Promise<T | null> {
const response = await apiRequest(key);
if (isNotAvailable(response.status)) return null;
if (!response.ok) {
throw await apiErrorFromResponse(response);
}
return response.json() as Promise<T>;
}
export async function apiNullableTextFetcher(key: string): Promise<string | null> {
const response = await apiRequest(key);
if (isNotAvailable(response.status)) return null;
if (!response.ok) {
throw await apiErrorFromResponse(response);
}
return response.text();
}
function paginatedApiPath(key: string, limit: number, offset: number): string {
const url = new URL(apiPath(key), "http://fabro.local");
url.searchParams.set("page[limit]", String(limit));
url.searchParams.set("page[offset]", String(offset));
return `${url.pathname}${url.search}`;
}
export async function apiPaginatedFetcher<TItem, TExtra extends object = {}>(
key: string,
): Promise<PaginatedEnvelope<TItem> & TExtra> {
const limit = 100;
let offset = 0;
const data: TItem[] = [];
let extras: TExtra | null = null;
let pagesLoaded = 0;
while (true) {
const response = await apiRequest(paginatedApiPath(key, limit, offset));
if (!response.ok) {
throw await apiErrorFromResponse(response);
}
const page = (await response.json()) as PaginatedEnvelope<TItem> & TExtra;
if (extras == null) {
const { data: _data, meta: _meta, ...rest } = page as PaginatedEnvelope<TItem> &
Record<string, unknown>;
extras = rest as TExtra;
}
pagesLoaded += 1;
const remainingItemBudget = PAGINATED_API_MAX_ITEMS - data.length;
const pageItems = remainingItemBudget > 0 ? page.data.slice(0, remainingItemBudget) : [];
data.push(...pageItems);
if (!page.meta.has_more || page.data.length === 0) {
return {
...(extras ?? ({} as TExtra)),
data,
meta: { has_more: false },
};
}
if (
pagesLoaded >= PAGINATED_API_MAX_PAGES ||
pageItems.length < page.data.length ||
data.length >= PAGINATED_API_MAX_ITEMS
) {
console.warn(
`Stopped paginated API fetch for ${key} after ${pagesLoaded} pages and ${data.length} items because the safety cap was reached.`,
);
return {
...(extras ?? ({} as TExtra)),
data,
meta: { has_more: true },
};
}
offset += page.data.length;
}
}
export async function apiJsonMutation<TResponse, TArg = unknown>(
key: string,
{ arg }: { arg: TArg },
): Promise<TResponse> {
const response = await apiRequest(key, {
init: {
method: "POST",
headers: { "Content-Type": "application/json" },
body: arg === undefined ? undefined : JSON.stringify(arg),
},
});
if (!response.ok) {
throw await apiErrorFromResponse(response);
}
if (response.status === 204) return undefined as TResponse;
return response.json() as Promise<TResponse>;
}