import { clearTokens, getAccessToken, getRefreshToken, setTokens } from '@/api/tokenStore' import type { ApiErrorBody, TokenPair } from '@/api/types' const API_PREFIX = '/api/v1' /** Эндпоинты, которые не должны получать Authorization и не должны запускать refresh. */ const PUBLIC_PATHS = new Set(['/auth/login', '/auth/refresh']) export class ApiError extends Error { readonly status: number constructor(status: number, message: string) { super(message) this.name = 'ApiError' this.status = status } } async function readErrorMessage(response: Response): Promise { try { const body = (await response.json()) as ApiErrorBody if (typeof body.detail === 'string') return body.detail if (Array.isArray(body.detail)) { return body.detail.map((item) => item.msg).join('; ') } } catch { // Тело не JSON или пустое — используем сообщение по статусу ниже. } if (response.status === 401) return 'Потрібно увійти в систему' if (response.status === 403) return 'Недостатньо прав для цієї дії' if (response.status >= 500) return 'Сервер тимчасово недоступний, спробуйте пізніше' return `Помилка запиту (${response.status})` } // Параллельные 401 не должны порождать несколько запросов на refresh — // все ждут один и тот же промис. let refreshPromise: Promise | null = null async function refreshAccessToken(): Promise { if (refreshPromise) return refreshPromise const refreshToken = getRefreshToken() if (!refreshToken) { throw new ApiError(401, 'Сесія закінчилася, увійдіть знову') } refreshPromise = (async () => { try { const response = await fetch(`${API_PREFIX}/auth/refresh`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ refresh_token: refreshToken }), }) if (!response.ok) { clearTokens() throw new ApiError(response.status, await readErrorMessage(response)) } const pair = (await response.json()) as TokenPair setTokens(pair.access_token, pair.refresh_token) return pair.access_token } finally { refreshPromise = null } })() return refreshPromise } export interface ApiFetchOptions extends Omit { body?: unknown } /** * Обёртка над fetch: подставляет Authorization, при первом 401 на защищённом * эндпоинте один раз молча обновляет access-токен и повторяет запрос. */ export async function apiFetch(path: string, options: ApiFetchOptions = {}): Promise { const isPublic = PUBLIC_PATHS.has(path) const accessToken = getAccessToken() const headers = new Headers(options.headers) headers.set('Content-Type', 'application/json') if (accessToken && !isPublic) { headers.set('Authorization', `Bearer ${accessToken}`) } const response = await fetch(`${API_PREFIX}${path}`, { ...options, headers, body: options.body !== undefined ? JSON.stringify(options.body) : undefined, }) if (response.status === 401 && !isPublic) { try { const newAccessToken = await refreshAccessToken() const retryHeaders = new Headers(options.headers) retryHeaders.set('Content-Type', 'application/json') retryHeaders.set('Authorization', `Bearer ${newAccessToken}`) const retryResponse = await fetch(`${API_PREFIX}${path}`, { ...options, headers: retryHeaders, body: options.body !== undefined ? JSON.stringify(options.body) : undefined, }) if (!retryResponse.ok) { throw new ApiError(retryResponse.status, await readErrorMessage(retryResponse)) } if (retryResponse.status === 204) return undefined as T return (await retryResponse.json()) as T } catch (err) { clearTokens() throw err } } if (!response.ok) { throw new ApiError(response.status, await readErrorMessage(response)) } if (response.status === 204) return undefined as T return (await response.json()) as T }