2026-09-19 18:20:59 +03:00
|
|
|
|
export interface ApiErrorBody {
|
|
|
|
|
|
error: {
|
|
|
|
|
|
code: string;
|
|
|
|
|
|
message: string;
|
|
|
|
|
|
details?: Record<string, unknown>;
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export class ApiError extends Error {
|
|
|
|
|
|
readonly code: string;
|
|
|
|
|
|
readonly status: number;
|
|
|
|
|
|
readonly details?: Record<string, unknown>;
|
|
|
|
|
|
|
|
|
|
|
|
constructor(status: number, code: string, message: string, details?: Record<string, unknown>) {
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-20 00:15:18 +03:00
|
|
|
|
/**
|
|
|
|
|
|
* Дополнительное поле конверта ошибки (например, `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<string, unknown>)[key];
|
|
|
|
|
|
if (value !== undefined) {
|
|
|
|
|
|
return value;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
const details = record.details;
|
|
|
|
|
|
if (typeof details === 'object' && details !== null) {
|
|
|
|
|
|
return (details as Record<string, unknown>)[key];
|
|
|
|
|
|
}
|
|
|
|
|
|
return undefined;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-19 18:20:59 +03:00
|
|
|
|
export interface RequestOptions {
|
|
|
|
|
|
method?: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
|
|
|
|
|
|
body?: unknown;
|
2026-09-20 01:32:11 +03:00
|
|
|
|
/** Тело multipart (загрузка файлов): заменяет JSON-тело. */
|
|
|
|
|
|
form?: FormData;
|
2026-09-19 18:20:59 +03:00
|
|
|
|
signal?: AbortSignal;
|
|
|
|
|
|
headers?: Record<string, string>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
2026-09-20 01:32:11 +03:00
|
|
|
|
// Multipart отдаёт браузер: Content-Type с boundary выставляет он сам.
|
|
|
|
|
|
const isForm = options.form !== undefined;
|
2026-09-19 18:20:59 +03:00
|
|
|
|
const init: RequestInit = {
|
|
|
|
|
|
method: options.method ?? 'GET',
|
|
|
|
|
|
credentials: 'same-origin',
|
|
|
|
|
|
headers: {
|
|
|
|
|
|
Accept: 'application/json',
|
2026-09-20 01:32:11 +03:00
|
|
|
|
...(options.body === undefined || isForm ? {} : { 'Content-Type': 'application/json' }),
|
2026-09-19 18:20:59 +03:00
|
|
|
|
...options.headers,
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
2026-09-20 01:32:11 +03:00
|
|
|
|
if (isForm) {
|
|
|
|
|
|
init.body = options.form ?? null;
|
|
|
|
|
|
} else if (options.body !== undefined) {
|
2026-09-19 18:20:59 +03:00
|
|
|
|
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;
|
|
|
|
|
|
}
|