94 lines
3.2 KiB
TypeScript
94 lines
3.2 KiB
TypeScript
import { env } from "./config";
|
|
import { getAccessToken, refreshTokens } from "./auth";
|
|
export { sessionMemory } from "./session";
|
|
import { sessionMemory } from "./session";
|
|
|
|
export type ApiErrorEnvelope = {
|
|
error: { code: string; message: string; request_id?: string; details?: Record<string, unknown> };
|
|
};
|
|
|
|
export class ApiError extends Error {
|
|
constructor(
|
|
readonly status: number,
|
|
readonly code: string,
|
|
message: string,
|
|
readonly requestId?: string,
|
|
readonly retryAfter?: number,
|
|
) {
|
|
super(message);
|
|
this.name = "ApiError";
|
|
}
|
|
}
|
|
|
|
export type Diagnostic = {
|
|
at: number;
|
|
method: string;
|
|
path: string;
|
|
status: number;
|
|
requestId: string;
|
|
};
|
|
|
|
const diagnostics: Diagnostic[] = [];
|
|
|
|
export const getDiagnostics = () => [...diagnostics];
|
|
|
|
function traceparent() {
|
|
const traceId = crypto.randomUUID().replaceAll("-", "") + crypto.randomUUID().replaceAll("-", "").slice(0, 16);
|
|
const spanId = crypto.randomUUID().replaceAll("-", "").slice(0, 16);
|
|
return `00-${traceId.slice(0, 32)}-${spanId}-01`;
|
|
}
|
|
|
|
function safePath(path: string) {
|
|
return path.split("?")[0] ?? path;
|
|
}
|
|
|
|
async function parseError(response: Response, requestId: string) {
|
|
let envelope: ApiErrorEnvelope | undefined;
|
|
try { envelope = (await response.json()) as ApiErrorEnvelope; } catch { /* intentionally empty */ }
|
|
const code = envelope?.error?.code ?? `http_${response.status}`;
|
|
const retry = Number(response.headers.get("Retry-After"));
|
|
return new ApiError(
|
|
response.status,
|
|
code,
|
|
envelope?.error?.message ?? "Запрос не выполнен",
|
|
envelope?.error?.request_id ?? requestId,
|
|
Number.isFinite(retry) ? retry : undefined,
|
|
);
|
|
}
|
|
|
|
export async function apiRequest<T>(
|
|
path: string,
|
|
init: RequestInit & { protected?: boolean } = {},
|
|
replayed = false,
|
|
): Promise<T> {
|
|
const requestId = crypto.randomUUID();
|
|
const isProtected = init.protected ?? false;
|
|
const headers = new Headers(init.headers);
|
|
headers.set("Accept", "application/json");
|
|
headers.set("X-Request-ID", requestId);
|
|
headers.set("traceparent", traceparent());
|
|
if (init.body && !headers.has("Content-Type")) headers.set("Content-Type", "application/json");
|
|
if (isProtected) {
|
|
const token = getAccessToken();
|
|
if (!token) throw new ApiError(401, "unauthorized", "Требуется авторизация", requestId);
|
|
headers.set("Authorization", `Bearer ${token}`);
|
|
if (sessionMemory.id) headers.set("X-Ux-Session-Id", sessionMemory.id);
|
|
}
|
|
const response = await fetch(`${env.apiBaseUrl}${path}`, { ...init, headers });
|
|
diagnostics.unshift({
|
|
at: Date.now(), method: init.method ?? "GET", path: safePath(path),
|
|
status: response.status, requestId: response.headers.get("X-Request-ID") ?? requestId,
|
|
});
|
|
diagnostics.splice(20);
|
|
if (response.status === 401 && isProtected && !replayed) {
|
|
await refreshTokens();
|
|
return apiRequest<T>(path, init, true);
|
|
}
|
|
if (!response.ok) throw await parseError(response, requestId);
|
|
if (response.status === 204) return undefined as T;
|
|
return response.json() as Promise<T>;
|
|
}
|
|
|
|
export const json = (value: unknown) => JSON.stringify(value);
|
|
export const idempotencyHeaders = (key: string) => ({ "Idempotency-Key": key });
|