export interface ApiErrorBody { error: { code: string; message: string; details?: Record; }; } export class ApiError extends Error { readonly code: string; readonly status: number; readonly details?: Record; constructor(status: number, code: string, message: string, details?: Record) { super(message); this.name = 'ApiError'; this.status = status; this.code = code; if (details !== undefined) { this.details = details; } } } const API_PREFIX = '/api/v1'; export function isApiErrorPayload(value: unknown): value is ApiErrorBody['error'] { if (typeof value !== 'object' || value === null) { return false; } const candidate = value as { code?: unknown; message?: unknown }; return typeof candidate.code === 'string' && typeof candidate.message === 'string'; } export function apiErrorCode(error: unknown): string | null { if (error === null || typeof error !== 'object') { return null; } const payload = (error as { apiError?: unknown }).apiError; return isApiErrorPayload(payload) ? payload.code : null; } /** * Дополнительное поле конверта ошибки (например, `retry_after_ms` у 429). * * Сервер кладёт детали рядом с `code`/`message`, а не во вложенный `details`, * поэтому ищем значение в исходном конверте; `details` остаётся запасным * вариантом для ошибок, собранных вручную. */ export function apiErrorField(error: unknown, key: string): unknown { if (error === null || typeof error !== 'object') { return undefined; } const record = error as { apiError?: unknown; details?: unknown }; if (isApiErrorPayload(record.apiError)) { const value = (record.apiError as unknown as Record)[key]; if (value !== undefined) { return value; } } const details = record.details; if (typeof details === 'object' && details !== null) { return (details as Record)[key]; } return undefined; } export interface RequestOptions { method?: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'; body?: unknown; /** Тело multipart (загрузка файлов): заменяет JSON-тело. */ form?: FormData; signal?: AbortSignal; headers?: Record; } export function parsePayload(text: string, contentType: string | null): unknown { if (text.length === 0) { return null; } if (contentType !== null && !contentType.includes('application/json')) { return null; } try { return JSON.parse(text) as unknown; } catch { return null; } } export async function request(path: string, options: RequestOptions = {}): Promise { // Multipart отдаёт браузер: Content-Type с boundary выставляет он сам. const isForm = options.form !== undefined; const init: RequestInit = { method: options.method ?? 'GET', credentials: 'same-origin', headers: { Accept: 'application/json', ...(options.body === undefined || isForm ? {} : { 'Content-Type': 'application/json' }), ...options.headers, }, }; if (isForm) { init.body = options.form ?? null; } else if (options.body !== undefined) { init.body = JSON.stringify(options.body); } if (options.signal !== undefined) { init.signal = options.signal; } const response = await fetch(`${API_PREFIX}${path}`, init); const text = await response.text(); const payload = parsePayload(text, response.headers.get('Content-Type')); if (!response.ok) { const envelope = typeof payload === 'object' && payload !== null ? (payload as { error?: unknown }).error : undefined; if (isApiErrorPayload(envelope)) { const error = new ApiError( response.status, envelope.code, envelope.message, envelope.details, ); Object.defineProperty(error, 'apiError', { value: envelope, enumerable: false, writable: false, }); throw error; } throw new ApiError(response.status, 'internal.error', 'unexpected response'); } return payload as T; }