feat(logos): Datenschicht, API-Aufrufe und übersetzbare API-Fehler
- logos/logos.ts (Muster community/gallery.ts): CommunityLogo, toLogo, logoQueries (Beliebt/Neu, Admin-Filter Gemeldet und
Ausgeblendet, Cursor), listLogos (nextCursor nur bei voller Seite von 24), getLogo (ungültige ID oder 404 -> null),
myHearts (eigene Herzen einer Seite, ohne IDs keine Anfrage), listLogoReports (ohne Melder-Hash), loadLogoBlob.
- logos/logo-api.ts: uploadLogo, setHeart, reportLogo (ohne Anmeldung), deleteLogo, setLogoVisibility über apiPost.
uploadLogo behandelt das Limit (429 tooManyLogos) wie publishCard als eindeutige Ablehnung (die hochgeladene Datei darf
aufgeräumt werden); eine Antwort ohne id (und bei setHeart ohne Zustand und Zähler) ist 503 mit offenem Ausgang.
- account/appwrite.ts: IDS um logos, logoLikes, logoReports und logoBucket.
- community/api.ts: ApiError.serverCode (der Schlüssel aus {error, code}; code bleibt der HTTP-Status, message der deutsche
Text). Client-Fehler tragen unavailable (auch 502/503/504 vom Proxy, vor dem Lesen des Bodys), generic, jwtUnsupported,
alreadyPublished, tooManyPublications. Neu: unavailableError() und apiErrorText(err). Die deutschen Texte stehen einmal
in api-messages.ts, die Vergleiche in publishCard laufen unverändert über err.message.
- i18n/api-messages.ts: API_MESSAGES (alle 33 Texte von MSG plus die Client-Codes generic und jwtUnsupported) und
apiMessage: bekannter Code -> t(Text); unbekannt: Deutsch der Servertext, Englisch eine Meldung je Status (400, 403, 404,
sonst allgemein). Der Code badRequest trägt auch die dynamischen Prüftexte des Dienstes, Deutsch zeigt dann den
genaueren Servertext.
- i18n/en.ts: englische Texte zu allen API_MESSAGES.
- Tests: api-messages.test.ts lädt MSG aus deploy/api/common.js (createRequire) und prüft den Gleichlauf in beide
Richtungen; logos.test.ts, logo-api.test.ts und neue Fälle in api.test.ts (der Helfer freshApi stellt die frische
i18n-Instanz auf Deutsch). 156 neue Tests, die bisherigen unverändert grün (1285 gesamt).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
fc9e5f5577
commit
3051b3b3f9
10 files changed
+1491
-21
No files matched your search
@@ -25,10 +25,18 @@ type Api = typeof import('./api');
|
||||
const UNAVAILABLE = 'Der Dienst ist gerade nicht erreichbar.';
|
||||
const MINUTE = 60_000;
|
||||
|
||||
// Der JWT-Cache lebt im Modul; jeder Test bekommt eine frische Instanz.
|
||||
// Der JWT-Cache lebt im Modul; jeder Test bekommt eine frische Instanz. Das gilt auch für i18n: die frische Instanz
|
||||
// beginnt mit der Browsersprache (im Test Englisch) und wird wie im Test-Setup auf Deutsch gestellt.
|
||||
async function freshApi(): Promise<Api> {
|
||||
vi.resetModules();
|
||||
return import('./api');
|
||||
const api = await import('./api');
|
||||
(await import('../i18n/i18n')).setLang('de', { persist: false });
|
||||
return api;
|
||||
}
|
||||
|
||||
/** Stellt die Sprache der frischen Instanz um (dieselbe, die `freshApi` geladen hat). */
|
||||
async function setLanguage(lang: 'de' | 'en'): Promise<void> {
|
||||
(await import('../i18n/i18n')).setLang(lang, { persist: false });
|
||||
}
|
||||
|
||||
const json = (status: number, body: unknown) =>
|
||||
@@ -593,3 +601,256 @@ describe('withdrawCard, reportCard, setVisibility', () => {
|
||||
await expect(api.setVisibility('g1', true)).rejects.toMatchObject({ code: 403, message: 'Nur für Admins.' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('serverCode: der Schlüssel der Fehlerantwort', () => {
|
||||
const fail = (api: Api) => api.apiPost('/api/x', {}, { auth: false }).catch((e: unknown) => e);
|
||||
|
||||
it('ApiError trägt ohne Angabe serverCode null; das Feld lässt sich setzen, ohne code und definitive zu ändern', async () => {
|
||||
const api = await freshApi();
|
||||
expect(new api.ApiError(400, 'x').serverCode).toBeNull();
|
||||
expect(new api.ApiError(400, 'x', undefined, null).serverCode).toBeNull();
|
||||
const err = new api.ApiError(409, 'x', undefined, 'alreadyPublished');
|
||||
expect(err).toMatchObject({ code: 409, status: 409, message: 'x', definitive: true, serverCode: 'alreadyPublished' });
|
||||
expect(new api.ApiError(429, 'x', true, 'tooManyPublications')).toMatchObject({ definitive: true, serverCode: 'tooManyPublications' });
|
||||
});
|
||||
|
||||
it('wird aus {error, code} der Antwort gelesen; code bleibt der Status, message der deutsche Text', async () => {
|
||||
const api = await freshApi();
|
||||
fetchMock.mockResolvedValue(json(403, { error: 'Nur für Admins.', code: 'adminOnly' }));
|
||||
const err = await fail(api);
|
||||
expect(err).toBeInstanceOf(api.ApiError);
|
||||
expect(err).toMatchObject({ code: 403, status: 403, message: 'Nur für Admins.', serverCode: 'adminOnly' });
|
||||
});
|
||||
|
||||
it('ohne code in der Antwort (älterer Dienst, Proxy) bleibt serverCode null', async () => {
|
||||
const api = await freshApi();
|
||||
fetchMock.mockResolvedValue(json(403, { error: 'Nur für Admins.' }));
|
||||
expect(await fail(api)).toMatchObject({ code: 403, message: 'Nur für Admins.', serverCode: null });
|
||||
});
|
||||
|
||||
it.each([[42], [''], [null], [true], [['adminOnly']], [{ a: 1 }]])('ein code, der kein nichtleerer Text ist (%j), zählt nicht', async (code) => {
|
||||
const api = await freshApi();
|
||||
fetchMock.mockResolvedValue(json(403, { error: 'Nur für Admins.', code }));
|
||||
expect(await fail(api)).toMatchObject({ message: 'Nur für Admins.', serverCode: null });
|
||||
});
|
||||
|
||||
it('der Dienst sendet Dienst-Fehler mit ihrem Schlüssel: 429, 404, 409', async () => {
|
||||
const api = await freshApi();
|
||||
for (const [status, error, code] of [
|
||||
[429, 'Zu viele Meldungen – bitte später nochmal.', 'tooManyReports'],
|
||||
[404, 'Dieses Logo ist nicht (mehr) verfügbar.', 'logoGone'],
|
||||
[409, 'Diese Datei wird schon für ein Logo verwendet.', 'logoTaken'],
|
||||
] as const) {
|
||||
fetchMock.mockResolvedValueOnce(json(status, { error, code }));
|
||||
expect(await fail(api)).toMatchObject({ code: status, message: error, serverCode: code });
|
||||
}
|
||||
});
|
||||
|
||||
it('Netzfehler und Zeitlimit: unavailable', async () => {
|
||||
const api = await freshApi();
|
||||
fetchMock.mockRejectedValue(new TypeError('Failed to fetch'));
|
||||
expect(await fail(api)).toMatchObject({ code: 503, message: UNAVAILABLE, serverCode: 'unavailable' });
|
||||
vi.spyOn(AbortSignal, 'timeout').mockReturnValue(AbortSignal.abort(new DOMException('The operation timed out.', 'TimeoutError')));
|
||||
fetchMock.mockImplementation(async (_url, init) => {
|
||||
throw init.signal?.reason;
|
||||
});
|
||||
expect(await fail(api)).toMatchObject({ code: 503, serverCode: 'unavailable' });
|
||||
});
|
||||
|
||||
it.each([502, 503, 504])('Status %i vom Proxy (ohne code im Body, auch mit fremdem code): unavailable', async (status) => {
|
||||
const api = await freshApi();
|
||||
fetchMock.mockResolvedValueOnce(json(status, { error: 'irgendwas' }));
|
||||
expect(await fail(api)).toMatchObject({ code: 503, message: UNAVAILABLE, serverCode: 'unavailable' });
|
||||
fetchMock.mockResolvedValueOnce(json(status, { error: 'irgendwas', code: 'internal' }));
|
||||
expect(await fail(api)).toMatchObject({ code: 503, message: UNAVAILABLE, serverCode: 'unavailable' });
|
||||
});
|
||||
|
||||
it('Fehlerseite ohne JSON, HTML mit Status 200 und JSON, das kein Objekt ist: unavailable', async () => {
|
||||
const api = await freshApi();
|
||||
for (const response of [
|
||||
new Response('<html>404 page not found</html>', { status: 404 }),
|
||||
new Response('<!doctype html><title>AirCard</title>', { status: 200 }),
|
||||
new Response('"ok"', { status: 200 }),
|
||||
]) {
|
||||
fetchMock.mockResolvedValueOnce(response);
|
||||
expect(await fail(api)).toMatchObject({ code: 503, message: UNAVAILABLE, serverCode: 'unavailable' });
|
||||
}
|
||||
});
|
||||
|
||||
it('eine abgebrochene Antwort (res.text() scheitert): unavailable', async () => {
|
||||
const api = await freshApi();
|
||||
fetchMock.mockResolvedValue({ ok: true, status: 200, text: () => Promise.reject(new TypeError('network error')) } as unknown as Response);
|
||||
expect(await fail(api)).toMatchObject({ code: 503, serverCode: 'unavailable' });
|
||||
});
|
||||
|
||||
it('JSON-Fehler ohne Text und ohne code: generic', async () => {
|
||||
const api = await freshApi();
|
||||
fetchMock.mockResolvedValue(json(500, {}));
|
||||
expect(await fail(api)).toMatchObject({ code: 500, message: 'Etwas ist schiefgelaufen. Bitte nochmal versuchen.', serverCode: 'generic' });
|
||||
});
|
||||
|
||||
it('JSON-Fehler ohne Text, aber mit code: der code des Dienstes', async () => {
|
||||
const api = await freshApi();
|
||||
fetchMock.mockResolvedValue(json(404, { code: 'logoGone' }));
|
||||
expect(await fail(api)).toMatchObject({ code: 404, message: 'Etwas ist schiefgelaufen. Bitte nochmal versuchen.', serverCode: 'logoGone' });
|
||||
});
|
||||
|
||||
it('createJWT mit user_auth_method_unsupported: jwtUnsupported (und eindeutig)', async () => {
|
||||
const api = await freshApi();
|
||||
mocks.createJWT.mockRejectedValue(Object.assign(new Error('x'), { code: 501, type: 'user_auth_method_unsupported' }));
|
||||
const err = await api.apiPost('/api/x', {}, { auth: true }).catch((e: unknown) => e);
|
||||
expect(err).toMatchObject({ code: 501, serverCode: 'jwtUnsupported', definitive: true });
|
||||
});
|
||||
|
||||
it('der zweite Versuch nach einem 401 liefert den Schlüssel der zweiten Antwort', async () => {
|
||||
const api = await freshApi();
|
||||
fetchMock.mockImplementation(async () => json(401, { error: 'Bitte melde dich an.', code: 'unauthorized' }));
|
||||
const err = await api.apiPost('/api/x', {}, { auth: true }).catch((e: unknown) => e);
|
||||
expect(err).toMatchObject({ code: 401, message: 'Bitte melde dich an.', serverCode: 'unauthorized' });
|
||||
});
|
||||
|
||||
it('unavailableError(): 503, Ausgang offen, Schlüssel unavailable', async () => {
|
||||
const api = await freshApi();
|
||||
const err = api.unavailableError();
|
||||
expect(err).toBeInstanceOf(api.ApiError);
|
||||
expect(err).toMatchObject({ code: 503, message: UNAVAILABLE, serverCode: 'unavailable', definitive: false });
|
||||
expect(api.isDefinitiveFailure(err)).toBe(false);
|
||||
});
|
||||
|
||||
describe('beim Veröffentlichen', () => {
|
||||
const input = {
|
||||
title: 'Meine Karte',
|
||||
issuer: 'Beispielbank',
|
||||
tags: ['visa'],
|
||||
displayName: 'Kenso',
|
||||
imageFileId: 'img1',
|
||||
previewFileId: 'pre1',
|
||||
sourceCardId: 'card1',
|
||||
};
|
||||
|
||||
it('403 „Dateien belegt“: der Dienst sendet filesTaken, publishCard macht daraus 409 alreadyPublished', async () => {
|
||||
const api = await freshApi();
|
||||
const text = 'Diese Bilder sind bereits veröffentlicht oder gesperrt.';
|
||||
fetchMock.mockImplementation(async () => json(403, { error: text, code: 'filesTaken' })); // je Aufruf eine neue Response
|
||||
expect(await api.apiPost('/api/veroeffentlichen', input, { auth: true }).catch((e: unknown) => e)).toMatchObject({
|
||||
code: 403,
|
||||
message: text,
|
||||
serverCode: 'filesTaken',
|
||||
});
|
||||
const err = await api.publishCard(input).catch((e: unknown) => e);
|
||||
expect(err).toMatchObject({ code: 409, message: 'Diese Karte ist schon veröffentlicht.', serverCode: 'alreadyPublished' });
|
||||
expect(api.isAlreadyPublished(err)).toBe(true);
|
||||
});
|
||||
|
||||
it('409 vom Dienst: alreadyPublished', async () => {
|
||||
const api = await freshApi();
|
||||
fetchMock.mockResolvedValue(json(409, { error: 'Diese Karte ist schon veröffentlicht.', code: 'alreadyPublished' }));
|
||||
expect(await api.publishCard(input).catch((e: unknown) => e)).toMatchObject({ code: 409, serverCode: 'alreadyPublished' });
|
||||
});
|
||||
|
||||
it('429 „schon 20 Karten“: tooManyPublications, eindeutig', async () => {
|
||||
const api = await freshApi();
|
||||
const text = 'Du hast schon 20 Karten veröffentlicht. Zieh eine zurück, um eine neue zu veröffentlichen.';
|
||||
fetchMock.mockResolvedValue(json(429, { error: text, code: 'tooManyPublications' }));
|
||||
const err = await api.publishCard(input).catch((e: unknown) => e);
|
||||
expect(err).toMatchObject({ code: 429, message: text, serverCode: 'tooManyPublications', definitive: true });
|
||||
});
|
||||
|
||||
it('ohne die Schlüssel (Dienst von vorher) klappt die Umdeutung weiter über den deutschen Text', async () => {
|
||||
const api = await freshApi();
|
||||
fetchMock.mockResolvedValueOnce(json(403, { error: 'Diese Bilder sind bereits veröffentlicht oder gesperrt.' }));
|
||||
expect(await api.publishCard(input).catch((e: unknown) => e)).toMatchObject({ code: 409, serverCode: 'alreadyPublished' });
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
json(429, { error: 'Du hast schon 20 Karten veröffentlicht. Zieh eine zurück, um eine neue zu veröffentlichen.' }),
|
||||
);
|
||||
expect(await api.publishCard(input).catch((e: unknown) => e)).toMatchObject({ code: 429, serverCode: 'tooManyPublications', definitive: true });
|
||||
});
|
||||
|
||||
it('eine Antwort ohne id: unavailable, Ausgang offen', async () => {
|
||||
const api = await freshApi();
|
||||
fetchMock.mockResolvedValue(json(201, {}));
|
||||
const err = await api.publishCard(input).catch((e: unknown) => e);
|
||||
expect(err).toMatchObject({ code: 503, serverCode: 'unavailable', definitive: false });
|
||||
});
|
||||
|
||||
it('ein anderes 403 behält seinen Schlüssel', async () => {
|
||||
const api = await freshApi();
|
||||
fetchMock.mockResolvedValue(json(403, { error: 'Diese Datei gehört dir nicht.', code: 'foreignFile' }));
|
||||
expect(await api.publishCard(input).catch((e: unknown) => e)).toMatchObject({ code: 403, serverCode: 'foreignFile' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
type ApiErr = InstanceType<Api['ApiError']>;
|
||||
|
||||
/** Der ApiError, mit dem `promise` scheitert. */
|
||||
async function rejection(promise: Promise<unknown>): Promise<ApiErr> {
|
||||
try {
|
||||
await promise;
|
||||
} catch (err) {
|
||||
return err as ApiErr;
|
||||
}
|
||||
throw new Error('Die Anfrage ist nicht gescheitert.');
|
||||
}
|
||||
|
||||
describe('apiErrorText: die Fehlermeldung zum Anzeigen', () => {
|
||||
it('Deutsch: der Text des Dienstes, auch bei Fehlern ohne bekannten Schlüssel', async () => {
|
||||
const api = await freshApi();
|
||||
expect(api.apiErrorText(new api.ApiError(409, 'Diese Karte ist schon veröffentlicht.', undefined, 'alreadyPublished'))).toBe(
|
||||
'Diese Karte ist schon veröffentlicht.',
|
||||
);
|
||||
expect(api.apiErrorText(new api.ApiError(400, 'Der Titel ist zu lang (höchstens 60 Zeichen).', undefined, 'badRequest'))).toBe(
|
||||
'Der Titel ist zu lang (höchstens 60 Zeichen).',
|
||||
);
|
||||
expect(api.apiErrorText(new api.ApiError(418, 'Ich bin eine Teekanne.'))).toBe('Ich bin eine Teekanne.');
|
||||
});
|
||||
|
||||
it('Englisch: übersetzt über den Schlüssel', async () => {
|
||||
const api = await freshApi();
|
||||
await setLanguage('en');
|
||||
expect(api.apiErrorText(new api.ApiError(409, 'Diese Karte ist schon veröffentlicht.', undefined, 'alreadyPublished'))).toBe(
|
||||
'This card is already published.',
|
||||
);
|
||||
expect(api.apiErrorText(new api.ApiError(403, 'Nur für Admins.', undefined, 'adminOnly'))).toBe('Admins only.');
|
||||
});
|
||||
|
||||
it('Englisch: Fehler, die der Client selbst erzeugt (Netz, JWT, Veröffentlichen), sind übersetzt', async () => {
|
||||
const api = await freshApi();
|
||||
await setLanguage('en');
|
||||
fetchMock.mockRejectedValue(new TypeError('Failed to fetch'));
|
||||
const network = await rejection(api.apiPost('/api/x', {}, { auth: false }));
|
||||
expect(api.apiErrorText(network)).toBe('The service is currently unavailable.');
|
||||
|
||||
mocks.createJWT.mockRejectedValue(Object.assign(new Error('x'), { code: 501, type: 'user_auth_method_unsupported' }));
|
||||
const jwt = await rejection(api.apiPost('/api/x', {}, { auth: true }));
|
||||
expect(api.apiErrorText(jwt)).toBe('Token sign-in is turned off on the server – please let the site owner know.');
|
||||
|
||||
fetchMock.mockResolvedValue(json(500, {}));
|
||||
const generic = await rejection(api.apiPost('/api/x', {}, { auth: false }));
|
||||
expect(api.apiErrorText(generic)).toBe('Something went wrong. Please try again.');
|
||||
});
|
||||
|
||||
it('Englisch: unbekannter oder fehlender Schlüssel, allgemeine Meldung je Status (die ApiError.code)', async () => {
|
||||
const api = await freshApi();
|
||||
await setLanguage('en');
|
||||
expect(api.apiErrorText(new api.ApiError(400, 'Das gibt es nur auf Deutsch.'))).toBe('Invalid request.');
|
||||
expect(api.apiErrorText(new api.ApiError(403, 'Das gibt es nur auf Deutsch.', undefined, 'neuerCode'))).toBe(
|
||||
'You are not allowed to do that.',
|
||||
);
|
||||
expect(api.apiErrorText(new api.ApiError(404, 'Das gibt es nur auf Deutsch.'))).toBe('Not found.');
|
||||
expect(api.apiErrorText(new api.ApiError(500, 'Das gibt es nur auf Deutsch.'))).toBe('Something went wrong. Please try again.');
|
||||
});
|
||||
|
||||
it('übergibt Schlüssel, Status und Text an apiMessage', async () => {
|
||||
const api = await freshApi();
|
||||
const { apiMessage } = await import('../i18n/api-messages');
|
||||
await setLanguage('en');
|
||||
for (const err of [
|
||||
new api.ApiError(404, 'Dieses Logo ist nicht (mehr) verfügbar.', undefined, 'logoGone'),
|
||||
new api.ApiError(400, 'Der Name ist ungültig.', undefined, 'badRequest'),
|
||||
new api.ApiError(403, 'Text', undefined, null),
|
||||
]) {
|
||||
expect(api.apiErrorText(err)).toBe(apiMessage(err.serverCode, err.code, err.message));
|
||||
}
|
||||
});
|
||||
});
|
||||
+47
-18
@@ -4,6 +4,7 @@
|
||||
import { account } from '../account/appwrite';
|
||||
import { currentUserId, onAuthChange, onLogout } from '../account/auth';
|
||||
import { errorType } from '../account/errors';
|
||||
import { API_MESSAGES, apiMessage } from '../i18n/api-messages';
|
||||
|
||||
export type ReportReason = 'unangemessen' | 'kartendaten' | 'urheberrecht' | 'spam' | 'sonstiges';
|
||||
|
||||
@@ -17,14 +18,16 @@ export interface PublishInput {
|
||||
sourceCardId: string;
|
||||
}
|
||||
|
||||
const UNAVAILABLE = 'Der Dienst ist gerade nicht erreichbar.';
|
||||
const GENERIC = 'Etwas ist schiefgelaufen. Bitte nochmal versuchen.';
|
||||
const JWT_UNSUPPORTED = 'Anmeldung per Token ist auf dem Server ausgeschaltet – bitte den Betreiber informieren.';
|
||||
const ALREADY_PUBLISHED = 'Diese Karte ist schon veröffentlicht.';
|
||||
/** Wortlaut der API (deploy/api/app.js, MSG.filesTaken): die hochgeladenen Dateien sind schon belegt oder gesperrt. */
|
||||
const FILES_TAKEN = 'Diese Bilder sind bereits veröffentlicht oder gesperrt.';
|
||||
// Die deutschen Texte stehen einmal in i18n/api-messages.ts (dort mit MSG des Dienstes abgeglichen); `ApiError.message` bleibt
|
||||
// Deutsch, damit der Vergleich mit ihnen weiter geht. Angezeigt wird über `apiErrorText`.
|
||||
const UNAVAILABLE = API_MESSAGES.unavailable;
|
||||
const GENERIC = API_MESSAGES.generic;
|
||||
const JWT_UNSUPPORTED = API_MESSAGES.jwtUnsupported;
|
||||
const ALREADY_PUBLISHED = API_MESSAGES.alreadyPublished;
|
||||
/** Wortlaut der API (deploy/api/common.js, MSG.filesTaken): die hochgeladenen Dateien sind schon belegt oder gesperrt. */
|
||||
const FILES_TAKEN = API_MESSAGES.filesTaken;
|
||||
/** Wortlaut der API (MSG.tooManyPublications): das Limit von 20 Veröffentlichungen je Konto ist erreicht. */
|
||||
const TOO_MANY_PUBLICATIONS = 'Du hast schon 20 Karten veröffentlicht. Zieh eine zurück, um eine neue zu veröffentlichen.';
|
||||
const TOO_MANY_PUBLICATIONS = API_MESSAGES.tooManyPublications;
|
||||
/** Appwrite-JWTs gelten 15 Minuten; wir verwenden eines höchstens 10 Minuten. */
|
||||
const JWT_TTL_MS = 10 * 60 * 1000;
|
||||
|
||||
@@ -33,6 +36,9 @@ const REQUEST_TIMEOUT_MS = 15_000;
|
||||
|
||||
/**
|
||||
* Fehler des API-Dienstes. `code` ist der HTTP-Status (`status` dasselbe), `message` der deutsche Servertext.
|
||||
* `serverCode` ist der Schlüssel der Fehlerantwort (`code` in `{error, code}`, siehe MSG in deploy/api/common.js; Fehler, die
|
||||
* der Client selbst erzeugt, tragen die passenden Schlüssel `unavailable`, `generic`, `jwtUnsupported`, …), null ohne Schlüssel.
|
||||
* Übersetzt wird der Text nach `serverCode` (`apiErrorText`), nie nach `message`.
|
||||
* `definitive`: der Dienst hat die Anfrage eindeutig abgelehnt oder es ging gar keine raus, also wurde nichts angelegt.
|
||||
* Ohne Angabe gilt das für 4xx außer Zeitüberschreitung (408) und Drosselung (429); 5xx, 408, 429 und alles, was
|
||||
* keine verwertbare Antwort ergab (Netzfehler, Zeitlimit, Fehlerseite, fehlende `id`), lassen offen, ob die
|
||||
@@ -41,12 +47,14 @@ const REQUEST_TIMEOUT_MS = 15_000;
|
||||
export class ApiError extends Error {
|
||||
readonly code: number;
|
||||
readonly definitive: boolean;
|
||||
readonly serverCode: string | null;
|
||||
|
||||
constructor(code: number, message: string, definitive?: boolean) {
|
||||
constructor(code: number, message: string, definitive?: boolean, serverCode?: string | null) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
this.code = code;
|
||||
this.definitive = definitive ?? (code >= 400 && code < 500 && code !== 408 && code !== 429);
|
||||
this.serverCode = serverCode ?? null;
|
||||
}
|
||||
|
||||
get status(): number {
|
||||
@@ -54,6 +62,16 @@ export class ApiError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/** Der Dienst gab keine verwertbare Antwort: 503, Ausgang offen (die Anfrage kann trotzdem gewirkt haben). */
|
||||
export function unavailableError(): ApiError {
|
||||
return new ApiError(503, UNAVAILABLE, false, 'unavailable');
|
||||
}
|
||||
|
||||
/** Der Text eines Fehlers zum Anzeigen, in der Sprache der Seite (bekannter `serverCode` übersetzt, siehe `apiMessage`). */
|
||||
export function apiErrorText(err: ApiError): string {
|
||||
return apiMessage(err.serverCode, err.code, err.message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wahr, wenn eine fehlgeschlagene `apiPost`-Anfrage sicher nichts bewirkt hat: eindeutige Ablehnung des Dienstes,
|
||||
* oder ein Fehler vor der Anfrage (JWT holen). Falsch bei offenem Ausgang (siehe `ApiError.definitive`).
|
||||
@@ -96,7 +114,7 @@ async function mintJwt(userId: string | null, started: number): Promise<string>
|
||||
if (errorType(err) === 'user_auth_method_unsupported') {
|
||||
const code = (err as { code?: unknown }).code;
|
||||
// Es ging keine Anfrage an den API-Dienst raus: eindeutig.
|
||||
throw new ApiError(typeof code === 'number' ? code : 501, JWT_UNSUPPORTED, true);
|
||||
throw new ApiError(typeof code === 'number' ? code : 501, JWT_UNSUPPORTED, true, 'jwtUnsupported');
|
||||
}
|
||||
throw err; // z. B. 401 ohne Sitzung: withLogin erkennt es am Statuscode
|
||||
}
|
||||
@@ -134,6 +152,12 @@ function serverMessage(body: Json): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Der Schlüssel einer Fehlerantwort (`code` neben `error`), sonst null. */
|
||||
function serverCodeOf(body: Json): string | null {
|
||||
const value = body.code;
|
||||
return typeof value === 'string' && value !== '' ? value : null;
|
||||
}
|
||||
|
||||
async function send(path: string, body: unknown, jwt: string | null): Promise<Json> {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (jwt) headers['X-Aircard-Jwt'] = jwt;
|
||||
@@ -152,18 +176,23 @@ async function send(path: string, body: unknown, jwt: string | null): Promise<Js
|
||||
text = await res.text();
|
||||
} catch {
|
||||
// Netzfehler, Zeitlimit oder abgebrochene Antwort: die Anfrage kann trotzdem angekommen sein.
|
||||
throw new ApiError(503, UNAVAILABLE);
|
||||
throw unavailableError();
|
||||
}
|
||||
// Der Proxy antwortet, wenn der Dienst fehlt oder steht (502/503/504, Fehlerseite, Seitenfallback als HTML-200).
|
||||
if (res.status === 502 || res.status === 503 || res.status === 504) throw new ApiError(503, UNAVAILABLE);
|
||||
// Der Proxy antwortet, wenn der Dienst fehlt oder steht (502/503/504, Fehlerseite, Seitenfallback als HTML-200). Sein Body
|
||||
// trägt keinen `code`; darum wird schon der Status (vor dem Lesen des Bodys) zu `unavailable`.
|
||||
if (res.status === 502 || res.status === 503 || res.status === 504) throw unavailableError();
|
||||
let data: unknown;
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch {
|
||||
throw new ApiError(503, UNAVAILABLE);
|
||||
throw unavailableError();
|
||||
}
|
||||
if (!isRecord(data)) throw unavailableError();
|
||||
if (!res.ok) {
|
||||
const message = serverMessage(data);
|
||||
// Ohne Text und ohne Schlüssel: der allgemeine Fehler.
|
||||
throw new ApiError(res.status, message ?? GENERIC, undefined, serverCodeOf(data) ?? (message === null ? 'generic' : null));
|
||||
}
|
||||
if (!isRecord(data)) throw new ApiError(503, UNAVAILABLE);
|
||||
if (!res.ok) throw new ApiError(res.status, serverMessage(data) ?? GENERIC);
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -200,14 +229,14 @@ export async function publishCard(input: PublishInput): Promise<{ id: string }>
|
||||
res = await apiPost('/api/veroeffentlichen', input, { auth: true });
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.code === 403 && err.message === FILES_TAKEN) {
|
||||
throw new ApiError(409, ALREADY_PUBLISHED);
|
||||
throw new ApiError(409, ALREADY_PUBLISHED, undefined, 'alreadyPublished');
|
||||
}
|
||||
if (err instanceof ApiError && err.code === 429 && err.message === TOO_MANY_PUBLICATIONS) {
|
||||
throw new ApiError(429, TOO_MANY_PUBLICATIONS, true);
|
||||
throw new ApiError(429, TOO_MANY_PUBLICATIONS, true, 'tooManyPublications');
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
if (typeof res.id !== 'string' || res.id === '') throw new ApiError(503, UNAVAILABLE);
|
||||
if (typeof res.id !== 'string' || res.id === '') throw unavailableError();
|
||||
return { id: res.id };
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user