dorfteich/apps/web/src/lib/api.ts
Claude Fable 5 704ebe48a6
Some checks failed
CD / Smoke tests against Test (push) Blocked by required conditions
CD / Deploy to Test (push) Blocked by required conditions
CD / Promote to Int (push) Blocked by required conditions
CI / Lint, typecheck, test (push) Failing after 1m0s
CI / Auth e2e pack (push) Has been skipped
CI / Import/export fidelity gate (push) Has been skipped
CI / Build container images (push) Has been skipped
CD / Build and push images (push) Has been cancelled
Vault import dialog in the pond settings (#118)
An admin-only 'Import an Obsidian vault' section on the pond settings
page opens a dialog with everything the #117 endpoint expects: the ZIP,
an indented mount-parent picker over the page tree (the MovePageDialog
pattern), a multi-select over the pond's label tree, and the
frontmatter radio (strip / keep as code block). Submit uploads and
polls the job with a vault-sized budget (600 x 1 s), then invalidates
pages, graph, phantom-links, and labels so the sidebar tree, graph, and
pickers show the import without a reload — and links to the mount page.

apiUploadFile now takes extra multipart fields (the options JSON);
existing callers are unchanged.

e2e import-vault.spec.ts: an admin imports the fixture vault through
the dialog and the app shows the folder tree under the mount page, a
rewritten Obsidian link navigates to the right page, the embedded image
renders, and the nested tag labels exist next to the dialog's extra
label; a plain editor gets no section at all. 3x flake-free locally
(CI wiring lands with #119).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 18:26:26 +02:00

115 lines
4.0 KiB
TypeScript

import type { ApiErrorBody, HealthResponse } from '@dorfteich/shared';
/**
* Typed fetch helper for the REST api. Non-2xx responses reject with an
* ApiError carrying the uniform body — callers translate `code` via the
* errors namespace and map `details` onto form fields.
*/
export class ApiError extends Error {
constructor(
readonly status: number,
readonly body: ApiErrorBody,
) {
super(body.message);
}
}
/**
* Fired once any api call answers 503 `maintenance_mode` (an in-app backup
* restore is running, issue #103) — App.tsx listens and swaps the UI for
* the maintenance screen, whatever the user was doing.
*/
export const MAINTENANCE_EVENT = 'dorfteich:maintenance';
function noteMaintenance(status: number, body: ApiErrorBody | null): void {
if (status === 503 && body?.code === 'maintenance_mode') {
window.dispatchEvent(new CustomEvent(MAINTENANCE_EVENT));
}
}
async function requestJson<T>(method: string, path: string, body?: unknown): Promise<T> {
let response: Response;
try {
response = await fetch(`/api/v1${path}`, {
method,
headers: {
Accept: 'application/json',
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
} catch {
throw new ApiError(0, { code: 'network', message: 'network error' });
}
if (!response.ok) {
const parsed = (await response.json().catch(() => null)) as ApiErrorBody | null;
noteMaintenance(response.status, parsed);
throw new ApiError(
response.status,
parsed ?? { code: `http_${response.status}`, message: response.statusText },
);
}
// 201/204 responses may carry no body at all.
const text = await response.text();
return (text ? JSON.parse(text) : undefined) as T;
}
export const apiGet = <T>(path: string): Promise<T> => requestJson<T>('GET', path);
export const apiPost = <T>(path: string, body?: unknown): Promise<T> =>
requestJson<T>('POST', path, body);
export const apiPut = <T>(path: string, body?: unknown): Promise<T> =>
requestJson<T>('PUT', path, body);
export const apiPatch = <T>(path: string, body?: unknown): Promise<T> =>
requestJson<T>('PATCH', path, body);
export const apiDelete = <T>(path: string): Promise<T> => requestJson<T>('DELETE', path);
/** Multipart upload (issue #27/#28) — a FormData body, unlike every other
* endpoint here, so it can't share `requestJson`'s JSON serialization; the
* error handling is kept identical. */
export async function apiUploadFile<T>(
path: string,
file: File,
fields: Record<string, string> = {},
): Promise<T> {
const form = new FormData();
form.append('file', file);
for (const [name, value] of Object.entries(fields)) form.append(name, value);
let response: Response;
try {
response = await fetch(`/api/v1${path}`, { method: 'POST', body: form });
} catch {
throw new ApiError(0, { code: 'network', message: 'network error' });
}
if (!response.ok) {
const parsed = (await response.json().catch(() => null)) as ApiErrorBody | null;
throw new ApiError(
response.status,
parsed ?? { code: `http_${response.status}`, message: response.statusText },
);
}
return response.json() as Promise<T>;
}
export function fetchHealth(): Promise<HealthResponse> {
return apiGet<HealthResponse>('/healthz');
}
/** Plain-text response (issue #30's Markdown export) — every other endpoint
* here returns JSON, so this can't share `requestJson`'s `JSON.parse`. */
export async function apiGetText(path: string): Promise<string> {
let response: Response;
try {
response = await fetch(`/api/v1${path}`);
} catch {
throw new ApiError(0, { code: 'network', message: 'network error' });
}
if (!response.ok) {
const parsed = (await response.json().catch(() => null)) as ApiErrorBody | null;
throw new ApiError(
response.status,
parsed ?? { code: `http_${response.status}`, message: response.statusText },
);
}
return response.text();
}