gendesign/frontend/src/lib/api.ts
lekss361 2d58c3a203 fix(#264): apiFetch — global X-Session-Id header (custom POI scoring в UI)
apiFetch и apiFetchWithStatus не передавали X-Session-Id header, из-за
чего POST /analyze возвращал custom_poi_score_items: [] и score без учёта
пользовательских POI. Добавлен withSessionHeader() helper с SSR guard;
user-supplied headers имеют приоритет (backward compat).
2026-05-17 10:31:54 +03:00

92 lines
2.9 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()}`);
}
return response.json() as Promise<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 };
}