gendesign/frontend/src/lib/api.ts
Light1YT 9a2cf32787
Some checks failed
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
Deploy / deploy (push) Blocked by required conditions
Deploy / changes (push) Successful in 7s
Deploy / build-frontend (push) Has been cancelled
fix(api): handle 204 No Content в apiFetch (#1219)
apiFetch безусловно звал response.json() после !response.ok-check, что
падало SyntaxError'ом на пустом теле 204. DELETE /custom-pois/{id} и
DELETE /admin/site-finder/weight-profiles/{id} объявлены status_code=204
→ mutation promise реджектился → onSuccess/invalidateQueries не срабатывали
→ POI/профиль виден в UI до случайного рефетча, хотя реально удалён на
сервере. Silent fail для DELETE кастомных POI на SiteMap (#1219, P2).

Fix: на 204 или пустом теле resolve'имся с undefined (аналог семантики
apiFetchWithStatus). Сигнатура Promise<T> сохранена — 80 не-204 callers
backward-compatible; два DELETE-хука уже типизированы <unknown>/<void>
и корректно игнорируют возврат.

6 новых vitest кейсов (api.test.ts): 204, empty-body-200, 200+JSON, 404,
500, regression guard. 73/73 frontend тестов зелёные, lint+tsc clean.

Closes #1219
2026-06-13 05:13:44 +00:00

104 lines
3.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { getOrCreateSessionId } from "@/lib/sessionId";
// Empty default = same-origin relative URLs.
// In prod Caddy proxies /api/* to backend; in dev Next.js rewrites do the same.
export const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
/**
* Merges a global X-Session-Id header into the request init.
* Only runs in browser (SSR guard). User-supplied headers win over the
* auto-injected value so explicit overrides remain backward-compatible.
*/
function withSessionHeader(init?: RequestInit): RequestInit {
const base: RequestInit = init ?? {};
if (typeof window === "undefined") return base;
const sessionId = getOrCreateSessionId();
if (!sessionId) return base;
return {
...base,
headers: {
"X-Session-Id": sessionId,
...(base.headers ?? {}),
},
};
}
export async function apiFetch<T>(
path: string,
init?: RequestInit,
): Promise<T> {
const merged = withSessionHeader(init);
const response = await fetch(`${API_BASE_URL}${path}`, {
...merged,
headers: {
"Content-Type": "application/json",
...(merged.headers ?? {}),
},
});
if (!response.ok) {
throw new Error(`API error ${response.status}: ${await response.text()}`);
}
// 204 No Content / пустое тело: `response.json()` бросит SyntaxError на пустой
// строке. Читаем как text один раз (stream consumable только раз) и парсим
// JSON только если непусто. На 204 / empty resolve'имся с undefined — caller
// для DELETE/no-content endpoints его игнорирует. Аналог поведения
// apiFetchWithStatus (см. ниже). Issue #1219.
if (response.status === 204) {
return undefined as T;
}
const rawText = await response.text();
if (!rawText) {
return undefined as T;
}
return JSON.parse(rawText) as T;
}
/**
* Status-aware variant of apiFetch — возвращает status + body для случаев
* когда логика зависит от HTTP кода (например 202 Accepted для async jobs).
*
* Throws на 4xx/5xx (используя HTTPError ниже); 2xx коды все ОК.
*/
export class HTTPError extends Error {
status: number;
body: unknown;
constructor(status: number, body: unknown, msg?: string) {
super(msg ?? `API error ${status}`);
this.status = status;
this.body = body;
}
}
export async function apiFetchWithStatus<T>(
path: string,
init?: RequestInit,
): Promise<{ status: number; body: T }> {
const merged = withSessionHeader(init);
const response = await fetch(`${API_BASE_URL}${path}`, {
...merged,
headers: {
"Content-Type": "application/json",
...(merged.headers ?? {}),
},
});
let body: unknown = null;
// 204 No Content имеет пустое тело; для всех остальных читаем как text один раз
// (fetch body stream consumable только один раз — нельзя сначала .json() потом .text()).
if (response.status !== 204) {
const rawText = await response.text();
if (rawText) {
try {
body = JSON.parse(rawText);
} catch {
// Не JSON (HTML error page от Caddy / 502 / etc) — оборачиваем в detail
body = { detail: rawText };
}
}
}
if (!response.ok) {
const detail =
(body as { detail?: string })?.detail ?? `API error ${response.status}`;
throw new HTTPError(response.status, body, detail);
}
return { status: response.status, body: body as T };
}