gendesign/tradein-mvp/frontend/src/lib/api.ts
lekss361 3665a61e48
All checks were successful
Deploy Trade-In / changes (push) Successful in 5s
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 1m24s
Deploy Trade-In / deploy (push) Successful in 32s
fix(tradein-frontend): safeUrl validator, apiFetch dedup, ListingsCard a11y, error boundary (#512)
2026-05-24 11:47:38 +00:00

85 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";
// При basePath=/trade-in (production behind gendsgn.ru/trade-in/*) API calls
// должны идти на `/trade-in/api/...`, чтобы Caddy `handle_path /trade-in/api/*`
// проксировал на tradein-backend — а не в main `handle /api/*` (gendesign).
// В dev (без basePath) — empty prefix, Next.js rewrites относительный путь.
const BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH ?? "";
export const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? BASE_PATH;
/**
* 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 ?? {}),
},
};
}
/**
* 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 };
}
export async function apiFetch<T>(
path: string,
init?: RequestInit,
): Promise<T> {
const { body } = await apiFetchWithStatus<T>(path, init);
return body;
}