apiFetch и apiFetchWithStatus не передавали X-Session-Id header, из-за чего POST /analyze возвращал custom_poi_score_items: [] и score без учёта пользовательских POI. Добавлен withSessionHeader() helper с SSR guard; user-supplied headers имеют приоритет (backward compat).
92 lines
2.9 KiB
TypeScript
92 lines
2.9 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()}`);
|
||
}
|
||
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 };
|
||
}
|