Ein statischer Code traegt seinen Inhalt selbst — zweimal derselbe Inhalt ergibt zweimal exakt dasselbe Bild. Bisher entstand der zweite Eintrag stillschweigend. Das Anlegen antwortet nun mit 409 und nennt den vorhandenen Eintrag samt Namen und Datum; die Oberflaeche zeigt das als Warnung mit dem Knopf "Trotzdem ablegen". Eine Rueckfrage, keine Sperre — zwei Fassungen desselben Codes koennen gewollt sein. Verglichen wird Typ und Inhalt innerhalb der Gruppe, nicht die Gestaltung. Dynamische Codes bleiben aussen vor: zwei Kurz-Links auf dasselbe Ziel sind der Normalfall, sobald getrennt gezaehlt werden soll. ApiError traegt dafuer jetzt Status und Antwortkoerper — eine gemeldete Dublette ist keine Sackgasse, sondern eine Rueckfrage. Refs #6
95 lines
2.6 KiB
TypeScript
95 lines
2.6 KiB
TypeScript
/** Kleine Hilfen für Aufrufe der eigenen API aus Client-Komponenten. */
|
|
|
|
async function readError(response: Response): Promise<ApiError> {
|
|
let body: unknown = null;
|
|
try {
|
|
body = await response.json();
|
|
} catch {
|
|
// Antwort ohne JSON-Körper — unten kommt die allgemeine Meldung.
|
|
}
|
|
|
|
const message =
|
|
body !== null &&
|
|
typeof body === "object" &&
|
|
"error" in body &&
|
|
typeof (body as { error: unknown }).error === "string"
|
|
? (body as { error: string }).error
|
|
: `Die Anfrage ist fehlgeschlagen (HTTP ${response.status}).`;
|
|
|
|
return new ApiError(message, response.status, body);
|
|
}
|
|
|
|
/**
|
|
* Fehler einer eigenen API-Antwort.
|
|
*
|
|
* Trägt neben der Meldung auch den Status und den Antwortkörper: Manche
|
|
* Fehler sind keine Sackgasse, sondern eine Rückfrage — etwa die gemeldete
|
|
* Dublette beim Ablegen, zu der die Oberfläche den vorhandenen Eintrag
|
|
* anzeigen will.
|
|
*/
|
|
export class ApiError extends Error {
|
|
readonly status: number;
|
|
readonly details: unknown;
|
|
|
|
constructor(message: string, status = 0, details: unknown = null) {
|
|
super(message);
|
|
this.status = status;
|
|
this.details = details;
|
|
}
|
|
}
|
|
|
|
export async function apiJson<T>(
|
|
url: string,
|
|
init?: RequestInit & { json?: unknown },
|
|
): Promise<T> {
|
|
const { json, ...rest } = init ?? {};
|
|
const response = await fetch(url, {
|
|
...rest,
|
|
headers:
|
|
json === undefined
|
|
? rest.headers
|
|
: { "Content-Type": "application/json", ...rest.headers },
|
|
body: json === undefined ? rest.body : JSON.stringify(json),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw await readError(response);
|
|
}
|
|
if (response.status === 204) {
|
|
return undefined as T;
|
|
}
|
|
return (await response.json()) as T;
|
|
}
|
|
|
|
/** Holt eine Datei von der API und stößt den Browser-Download an. */
|
|
export async function downloadFile(
|
|
url: string,
|
|
json: unknown,
|
|
fallbackName: string,
|
|
): Promise<void> {
|
|
const response = await fetch(url, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(json),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw await readError(response);
|
|
}
|
|
|
|
const disposition = response.headers.get("Content-Disposition") ?? "";
|
|
const match = /filename="([^"]+)"/.exec(disposition);
|
|
const blob = await response.blob();
|
|
const objectUrl = URL.createObjectURL(blob);
|
|
|
|
const anchor = document.createElement("a");
|
|
anchor.href = objectUrl;
|
|
anchor.download = match?.[1] ?? fallbackName;
|
|
document.body.append(anchor);
|
|
anchor.click();
|
|
anchor.remove();
|
|
|
|
// Der Browser braucht den Blob noch einen Moment für den Download.
|
|
setTimeout(() => URL.revokeObjectURL(objectUrl), 10_000);
|
|
}
|