2025-05-16 06:23:48 +00:00
|
|
|
|
/**
|
|
|
|
|
* API-клиент для работы с GraphQL
|
|
|
|
|
* @module api
|
|
|
|
|
*/
|
|
|
|
|
|
2025-05-19 08:25:41 +00:00
|
|
|
|
import { AUTH_TOKEN_KEY, CSRF_TOKEN_KEY, getAuthTokenFromCookie, getCsrfTokenFromCookie } from './auth'
|
2025-05-16 06:23:48 +00:00
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Тип для произвольных данных GraphQL
|
|
|
|
|
*/
|
|
|
|
|
type GraphQLData = Record<string, unknown>
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Обрабатывает ошибки от API
|
|
|
|
|
* @param response - Ответ от сервера
|
|
|
|
|
* @returns Обработанный текст ошибки
|
|
|
|
|
*/
|
|
|
|
|
async function handleApiError(response: Response): Promise<string> {
|
|
|
|
|
try {
|
|
|
|
|
const contentType = response.headers.get('content-type')
|
|
|
|
|
|
|
|
|
|
if (contentType?.includes('application/json')) {
|
|
|
|
|
const errorData = await response.json()
|
|
|
|
|
|
|
|
|
|
// Проверяем GraphQL ошибки
|
|
|
|
|
if (errorData.errors && errorData.errors.length > 0) {
|
|
|
|
|
return errorData.errors[0].message
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Проверяем сообщение об ошибке
|
|
|
|
|
if (errorData.error || errorData.message) {
|
|
|
|
|
return errorData.error || errorData.message
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Если не JSON или нет структурированной ошибки, читаем как текст
|
|
|
|
|
const errorText = await response.text()
|
|
|
|
|
return `Ошибка сервера: ${response.status} ${response.statusText}. ${errorText.substring(0, 100)}...`
|
|
|
|
|
} catch (_e) {
|
|
|
|
|
// Если не можем прочитать ответ
|
|
|
|
|
return `Ошибка сервера: ${response.status} ${response.statusText}`
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Проверяет наличие ошибок авторизации в ответе GraphQL
|
|
|
|
|
* @param errors - Массив ошибок GraphQL
|
|
|
|
|
* @returns true если есть ошибки авторизации
|
|
|
|
|
*/
|
|
|
|
|
function hasAuthErrors(errors: Array<{ message?: string; extensions?: { code?: string } }>): boolean {
|
|
|
|
|
return errors.some(
|
|
|
|
|
(error) =>
|
2025-05-16 07:30:02 +00:00
|
|
|
|
(error.message &&
|
|
|
|
|
(error.message.toLowerCase().includes('unauthorized') ||
|
|
|
|
|
error.message.toLowerCase().includes('авторизации') ||
|
|
|
|
|
error.message.toLowerCase().includes('authentication') ||
|
|
|
|
|
error.message.toLowerCase().includes('unauthenticated') ||
|
|
|
|
|
error.message.toLowerCase().includes('token'))) ||
|
2025-05-16 06:23:48 +00:00
|
|
|
|
error.extensions?.code === 'UNAUTHENTICATED' ||
|
|
|
|
|
error.extensions?.code === 'FORBIDDEN'
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2025-05-19 08:25:41 +00:00
|
|
|
|
/**
|
|
|
|
|
* Подготавливает URL для GraphQL запроса
|
|
|
|
|
* @param url - URL или путь для запроса
|
|
|
|
|
* @returns Полный URL для запроса
|
|
|
|
|
*/
|
|
|
|
|
function prepareUrl(url: string): string {
|
|
|
|
|
// Если это относительный путь, добавляем к нему origin
|
|
|
|
|
if (url.startsWith('/')) {
|
|
|
|
|
return `${location.origin}${url}`
|
|
|
|
|
}
|
|
|
|
|
// Если это уже полный URL, используем как есть
|
|
|
|
|
return url
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Возвращает заголовки для GraphQL запроса с учетом авторизации и CSRF
|
|
|
|
|
* @returns Объект с заголовками
|
|
|
|
|
*/
|
|
|
|
|
function getRequestHeaders(): Record<string, string> {
|
|
|
|
|
const headers: Record<string, string> = {
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
'Accept': 'application/json'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Проверяем наличие токена в localStorage
|
|
|
|
|
const localToken = localStorage.getItem(AUTH_TOKEN_KEY)
|
|
|
|
|
|
|
|
|
|
// Проверяем наличие токена в cookie
|
|
|
|
|
const cookieToken = getAuthTokenFromCookie()
|
|
|
|
|
|
|
|
|
|
// Используем токен из localStorage или cookie
|
|
|
|
|
const token = localToken || cookieToken
|
|
|
|
|
|
|
|
|
|
// Если есть токен, добавляем его в заголовок Authorization с префиксом Bearer
|
|
|
|
|
if (token && token.length > 10) {
|
|
|
|
|
headers['Authorization'] = `Bearer ${token}`
|
|
|
|
|
console.debug('Отправка запроса с токеном авторизации')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Добавляем CSRF-токен, если он есть
|
|
|
|
|
const csrfToken = getCsrfTokenFromCookie()
|
|
|
|
|
if (csrfToken) {
|
|
|
|
|
headers['X-CSRF-Token'] = csrfToken
|
|
|
|
|
console.debug('Добавлен CSRF-токен в запрос')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return headers
|
|
|
|
|
}
|
|
|
|
|
|
2025-05-16 06:23:48 +00:00
|
|
|
|
/**
|
|
|
|
|
* Выполняет GraphQL запрос
|
2025-05-16 07:30:02 +00:00
|
|
|
|
* @param url - URL для запроса
|
2025-05-16 06:23:48 +00:00
|
|
|
|
* @param query - GraphQL запрос
|
|
|
|
|
* @param variables - Переменные запроса
|
|
|
|
|
* @returns Результат запроса
|
|
|
|
|
*/
|
|
|
|
|
export async function query<T = GraphQLData>(
|
2025-05-16 07:30:02 +00:00
|
|
|
|
url: string,
|
2025-05-16 06:23:48 +00:00
|
|
|
|
query: string,
|
|
|
|
|
variables: Record<string, unknown> = {}
|
|
|
|
|
): Promise<T> {
|
|
|
|
|
try {
|
2025-05-19 08:25:41 +00:00
|
|
|
|
// Получаем все необходимые заголовки для запроса
|
|
|
|
|
const headers = getRequestHeaders()
|
2025-05-16 07:30:02 +00:00
|
|
|
|
|
2025-05-19 08:25:41 +00:00
|
|
|
|
// Подготавливаем полный URL
|
|
|
|
|
const fullUrl = prepareUrl(url)
|
|
|
|
|
console.debug('Отправка GraphQL запроса на:', fullUrl)
|
2025-05-16 06:23:48 +00:00
|
|
|
|
|
2025-05-19 08:25:41 +00:00
|
|
|
|
const response = await fetch(fullUrl, {
|
2025-05-16 06:23:48 +00:00
|
|
|
|
method: 'POST',
|
|
|
|
|
headers,
|
|
|
|
|
// Важно: credentials: 'include' - для передачи cookies с запросом
|
|
|
|
|
credentials: 'include',
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
query,
|
|
|
|
|
variables
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Проверяем статус ответа
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
const errorMessage = await handleApiError(response)
|
|
|
|
|
console.error('Ошибка API:', {
|
|
|
|
|
status: response.status,
|
|
|
|
|
statusText: response.statusText,
|
|
|
|
|
error: errorMessage
|
|
|
|
|
})
|
|
|
|
|
|
2025-05-19 08:25:41 +00:00
|
|
|
|
// Если получен 401 Unauthorized или 403 Forbidden, перенаправляем на страницу входа
|
|
|
|
|
if (response.status === 401 || response.status === 403) {
|
2025-05-16 06:23:48 +00:00
|
|
|
|
localStorage.removeItem(AUTH_TOKEN_KEY)
|
2025-05-16 07:30:02 +00:00
|
|
|
|
window.location.href = '/'
|
2025-05-16 06:23:48 +00:00
|
|
|
|
throw new Error('Unauthorized')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
throw new Error(errorMessage)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Проверяем, что ответ содержит JSON
|
|
|
|
|
const contentType = response.headers.get('content-type')
|
|
|
|
|
if (!contentType?.includes('application/json')) {
|
|
|
|
|
const text = await response.text()
|
|
|
|
|
throw new Error(`Неверный формат ответа: ${text.substring(0, 100)}...`)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const result = await response.json()
|
|
|
|
|
|
|
|
|
|
if (result.errors) {
|
|
|
|
|
// Проверяем ошибки на признаки проблем с авторизацией
|
|
|
|
|
if (hasAuthErrors(result.errors)) {
|
|
|
|
|
localStorage.removeItem(AUTH_TOKEN_KEY)
|
2025-05-16 07:30:02 +00:00
|
|
|
|
window.location.href = '/'
|
2025-05-16 06:23:48 +00:00
|
|
|
|
throw new Error('Unauthorized')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
throw new Error(result.errors[0].message)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return result.data as T
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('API Error:', error)
|
|
|
|
|
throw error
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Выполняет GraphQL мутацию
|
2025-05-16 07:30:02 +00:00
|
|
|
|
* @param url - URL для запроса
|
2025-05-16 06:23:48 +00:00
|
|
|
|
* @param mutation - GraphQL мутация
|
|
|
|
|
* @param variables - Переменные мутации
|
|
|
|
|
* @returns Результат мутации
|
|
|
|
|
*/
|
|
|
|
|
export function mutate<T = GraphQLData>(
|
2025-05-16 07:30:02 +00:00
|
|
|
|
url: string,
|
2025-05-16 06:23:48 +00:00
|
|
|
|
mutation: string,
|
|
|
|
|
variables: Record<string, unknown> = {}
|
|
|
|
|
): Promise<T> {
|
2025-05-16 07:30:02 +00:00
|
|
|
|
return query<T>(url, mutation, variables)
|
2025-05-16 06:23:48 +00:00
|
|
|
|
}
|