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
104 lines
3.5 KiB
TypeScript
104 lines
3.5 KiB
TypeScript
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 };
|
||
}
|