416 lines
15 KiB
TypeScript
416 lines
15 KiB
TypeScript
/**
|
||
* Base API client: authentication state, token refresh and HTTP transport.
|
||
*/
|
||
|
||
import type {
|
||
LoginResponse,
|
||
QrLoginRequestCreateResult,
|
||
QrLoginRequestStatusResult,
|
||
SearchModelType,
|
||
SearchResponse,
|
||
} from "@/types/api";
|
||
|
||
interface LoginCredentials {
|
||
login?: string;
|
||
email?: string;
|
||
phone?: string;
|
||
password: string;
|
||
}
|
||
|
||
interface BrowserSessionResponse {
|
||
redirect_url: string;
|
||
}
|
||
|
||
export type ApiRequestOptions = RequestInit & {
|
||
timeoutMs?: number;
|
||
};
|
||
|
||
export const AUTH_SESSION_EVENT = 'eusrr:auth-session';
|
||
|
||
export type AuthSessionEventDetail =
|
||
| { type: 'token-refreshed' }
|
||
| { type: 'recovery-required'; message: string }
|
||
| { type: 'session-cleared'; reason: 'expired' | 'logout' };
|
||
|
||
export class ApiError extends Error {
|
||
constructor(
|
||
public readonly status: number,
|
||
message: string,
|
||
) {
|
||
super(message);
|
||
this.name = 'ApiError';
|
||
}
|
||
}
|
||
|
||
export class ApiNetworkError extends Error {
|
||
constructor(message: string, options?: ErrorOptions) {
|
||
super(message, options);
|
||
this.name = 'ApiNetworkError';
|
||
}
|
||
}
|
||
|
||
export class AuthSessionExpiredError extends Error {
|
||
constructor(message = 'Сессия истекла. Войдите снова.') {
|
||
super(message);
|
||
this.name = 'AuthSessionExpiredError';
|
||
}
|
||
}
|
||
|
||
export class AuthRecoveryError extends Error {
|
||
constructor(message = 'Не удалось обновить сессию. Повторяем подключение.', options?: ErrorOptions) {
|
||
super(message, options);
|
||
this.name = 'AuthRecoveryError';
|
||
}
|
||
}
|
||
|
||
export function isRecoverableApiError(error: unknown): boolean {
|
||
return error instanceof AuthRecoveryError
|
||
|| error instanceof ApiNetworkError
|
||
|| (error instanceof ApiError && (error.status === 408 || error.status === 425 || error.status === 429 || error.status >= 500));
|
||
}
|
||
|
||
const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
|
||
const AUTH_REQUEST_TIMEOUT_MS = 15_000;
|
||
|
||
export class ApiClientBase {
|
||
private readonly tokenKey = 'access_token';
|
||
private readonly refreshTokenKey = 'refresh_token';
|
||
private refreshPromise: Promise<string> | null = null;
|
||
|
||
getToken(): string | null {
|
||
if (typeof window === 'undefined') return null;
|
||
return localStorage.getItem(this.tokenKey);
|
||
}
|
||
|
||
getRefreshToken(): string | null {
|
||
if (typeof window === 'undefined') return null;
|
||
return localStorage.getItem(this.refreshTokenKey);
|
||
}
|
||
|
||
hasStoredSession(): boolean {
|
||
return Boolean(this.getToken() || this.getRefreshToken());
|
||
}
|
||
|
||
getAccessTokenExpiresAt(): number | null {
|
||
const token = this.getToken();
|
||
if (!token) return null;
|
||
|
||
try {
|
||
const payload = token.split('.')[1];
|
||
if (!payload) return null;
|
||
const normalized = payload.replace(/-/g, '+').replace(/_/g, '/');
|
||
const decoded = JSON.parse(atob(normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=')));
|
||
return typeof decoded.exp === 'number' ? decoded.exp * 1000 : null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
private emitAuthEvent(detail: AuthSessionEventDetail): void {
|
||
if (typeof window !== 'undefined') {
|
||
window.dispatchEvent(new CustomEvent<AuthSessionEventDetail>(AUTH_SESSION_EVENT, { detail }));
|
||
}
|
||
}
|
||
|
||
private setToken(token: string): void {
|
||
if (typeof window === 'undefined') return;
|
||
localStorage.setItem(this.tokenKey, token);
|
||
this.emitAuthEvent({ type: 'token-refreshed' });
|
||
}
|
||
|
||
private setRefreshToken(token: string): void {
|
||
if (typeof window !== 'undefined') {
|
||
localStorage.setItem(this.refreshTokenKey, token);
|
||
}
|
||
}
|
||
|
||
clearToken(reason: 'expired' | 'logout' = 'logout'): void {
|
||
if (typeof window === 'undefined') return;
|
||
localStorage.removeItem(this.tokenKey);
|
||
localStorage.removeItem(this.refreshTokenKey);
|
||
this.emitAuthEvent({ type: 'session-cleared', reason });
|
||
}
|
||
|
||
private recoveryError(message?: string, cause?: unknown): AuthRecoveryError {
|
||
const error = new AuthRecoveryError(message, cause === undefined ? undefined : { cause });
|
||
this.emitAuthEvent({ type: 'recovery-required', message: error.message });
|
||
return error;
|
||
}
|
||
|
||
private async fetchWithTimeout(
|
||
endpoint: string,
|
||
options: ApiRequestOptions = {},
|
||
fallbackTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS,
|
||
): Promise<Response> {
|
||
const { timeoutMs = fallbackTimeoutMs, signal, ...requestOptions } = options;
|
||
const controller = new AbortController();
|
||
const abortFromCaller = () => controller.abort(signal?.reason);
|
||
|
||
if (signal?.aborted) {
|
||
abortFromCaller();
|
||
} else {
|
||
signal?.addEventListener('abort', abortFromCaller, { once: true });
|
||
}
|
||
|
||
const timeoutId = window.setTimeout(() => controller.abort('timeout'), timeoutMs);
|
||
|
||
try {
|
||
return await fetch(endpoint, { ...requestOptions, signal: controller.signal });
|
||
} catch (error) {
|
||
if (signal?.aborted) throw error;
|
||
const message = controller.signal.aborted
|
||
? 'Превышено время ожидания ответа сервера.'
|
||
: 'Не удалось соединиться с сервером.';
|
||
throw new ApiNetworkError(message, { cause: error });
|
||
} finally {
|
||
window.clearTimeout(timeoutId);
|
||
signal?.removeEventListener('abort', abortFromCaller);
|
||
}
|
||
}
|
||
|
||
private async performRefresh(): Promise<string> {
|
||
const refreshToken = this.getRefreshToken();
|
||
if (!refreshToken) {
|
||
this.clearToken('expired');
|
||
throw new AuthSessionExpiredError();
|
||
}
|
||
|
||
let response: Response;
|
||
try {
|
||
response = await this.fetchWithTimeout('/api/auth/token/refresh/', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ refresh: refreshToken }),
|
||
timeoutMs: AUTH_REQUEST_TIMEOUT_MS,
|
||
});
|
||
} catch (error) {
|
||
throw this.recoveryError(undefined, error);
|
||
}
|
||
|
||
if (response.status === 400 || response.status === 401) {
|
||
this.clearToken('expired');
|
||
throw new AuthSessionExpiredError();
|
||
}
|
||
|
||
if (!response.ok) {
|
||
throw this.recoveryError(`Сервер временно не может обновить сессию (HTTP ${response.status}).`);
|
||
}
|
||
|
||
let data: LoginResponse;
|
||
try {
|
||
data = await response.json();
|
||
} catch (error) {
|
||
throw this.recoveryError('Сервер вернул некорректный ответ при обновлении сессии.', error);
|
||
}
|
||
|
||
if (!data.access) {
|
||
throw this.recoveryError('В ответе обновления сессии отсутствует access-токен.');
|
||
}
|
||
|
||
if (data.refresh) this.setRefreshToken(data.refresh);
|
||
this.setToken(data.access);
|
||
return data.access;
|
||
}
|
||
|
||
refreshSession(): Promise<string> {
|
||
if (!this.refreshPromise) {
|
||
const accessBeforeRefresh = this.getToken();
|
||
const runRefresh = async () => {
|
||
const currentAccess = this.getToken();
|
||
if (currentAccess && currentAccess !== accessBeforeRefresh) {
|
||
return currentAccess;
|
||
}
|
||
return this.performRefresh();
|
||
};
|
||
const refresh: Promise<string> = typeof navigator !== 'undefined' && navigator.locks
|
||
? navigator.locks.request('eusrr-auth-refresh', runRefresh) as unknown as Promise<string>
|
||
: runRefresh();
|
||
|
||
const pendingRefresh = refresh.finally(() => {
|
||
this.refreshPromise = null;
|
||
});
|
||
this.refreshPromise = pendingRefresh;
|
||
return pendingRefresh;
|
||
}
|
||
return this.refreshPromise;
|
||
}
|
||
|
||
private buildHeaders(options: RequestInit, token = this.getToken()): Record<string, string> {
|
||
const headers: Record<string, string> = {
|
||
...(options.headers as Record<string, string>),
|
||
};
|
||
|
||
if (!(options.body instanceof FormData) && !headers['Content-Type']) {
|
||
headers['Content-Type'] = 'application/json';
|
||
}
|
||
|
||
if (token) headers.Authorization = `Bearer ${token}`;
|
||
return headers;
|
||
}
|
||
|
||
private async parseErrorDetails(response: Response): Promise<string> {
|
||
try {
|
||
const data = await response.json();
|
||
return data.detail || JSON.stringify(data);
|
||
} catch {
|
||
return response.statusText;
|
||
}
|
||
}
|
||
|
||
private async execute(endpoint: string, options: ApiRequestOptions): Promise<Response> {
|
||
let response = await this.fetchWithTimeout(endpoint, {
|
||
...options,
|
||
headers: this.buildHeaders(options),
|
||
});
|
||
|
||
if (response.status === 401 && this.getRefreshToken()) {
|
||
const newToken = await this.refreshSession();
|
||
response = await this.fetchWithTimeout(endpoint, {
|
||
...options,
|
||
headers: this.buildHeaders(options, newToken),
|
||
});
|
||
}
|
||
|
||
return response;
|
||
}
|
||
|
||
async requestRaw(endpoint: string, options: ApiRequestOptions = {}): Promise<Response> {
|
||
const response = await this.execute(endpoint, options);
|
||
if (!response.ok) {
|
||
const details = await this.parseErrorDetails(response);
|
||
throw new ApiError(response.status, `API Error: ${response.status} ${details}`);
|
||
}
|
||
return response;
|
||
}
|
||
|
||
async request<T>(endpoint: string, options: ApiRequestOptions = {}): Promise<T> {
|
||
const response = await this.requestRaw(endpoint, options);
|
||
if (response.status === 204 || response.headers.get('content-length') === '0') {
|
||
return undefined as T;
|
||
}
|
||
return response.json();
|
||
}
|
||
|
||
private async unauthenticatedRequest<T>(endpoint: string, options: ApiRequestOptions): Promise<T> {
|
||
const response = await this.fetchWithTimeout(endpoint, options, AUTH_REQUEST_TIMEOUT_MS);
|
||
if (!response.ok) {
|
||
const details = await this.parseErrorDetails(response);
|
||
throw new ApiError(response.status, `API Error: ${response.status} ${details}`);
|
||
}
|
||
if (response.status === 204) return undefined as T;
|
||
return response.json();
|
||
}
|
||
|
||
// Auth
|
||
|
||
async login(credentials: LoginCredentials): Promise<void> {
|
||
const data = await this.unauthenticatedRequest<LoginResponse>('/api/auth/token/', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(credentials),
|
||
});
|
||
this.setRefreshToken(data.refresh);
|
||
this.setToken(data.access);
|
||
}
|
||
|
||
async establishBrowserSession(nextUrl: string): Promise<string> {
|
||
const data = await this.request<BrowserSessionResponse>('/api/auth/browser-session/', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ next: nextUrl }),
|
||
});
|
||
return data.redirect_url;
|
||
}
|
||
|
||
async logoutBrowserSession(): Promise<void> {
|
||
await this.request<void>('/api/auth/browser-session/logout/', {
|
||
method: 'POST',
|
||
});
|
||
}
|
||
|
||
async loginWithQrToken(token: string): Promise<void> {
|
||
const data = await this.unauthenticatedRequest<LoginResponse>('/api/auth/qr-login/exchange/', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ token }),
|
||
});
|
||
this.setRefreshToken(data.refresh);
|
||
this.setToken(data.access);
|
||
}
|
||
|
||
async createQrLoginRequest(): Promise<QrLoginRequestCreateResult> {
|
||
return this.unauthenticatedRequest('/api/auth/qr-login/requests/', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
});
|
||
}
|
||
|
||
async pollQrLoginRequest(clientSecret: string): Promise<QrLoginRequestStatusResult> {
|
||
const data = await this.unauthenticatedRequest<QrLoginRequestStatusResult>('/api/auth/qr-login/requests/status/', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ client_secret: clientSecret }),
|
||
});
|
||
if (data.status === 'approved' && data.access && data.refresh) {
|
||
this.setRefreshToken(data.refresh);
|
||
this.setToken(data.access);
|
||
}
|
||
return data;
|
||
}
|
||
|
||
async cancelQrLoginRequest(clientSecret: string): Promise<void> {
|
||
await this.unauthenticatedRequest('/api/auth/qr-login/requests/cancel/', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ client_secret: clientSecret }),
|
||
});
|
||
}
|
||
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
async getCurrentUser(): Promise<any> {
|
||
return this.request('/api/v1/employees/me/');
|
||
}
|
||
|
||
async search(query: string, options: {
|
||
limit?: number;
|
||
offset?: number;
|
||
modelType?: SearchModelType;
|
||
} = {}): Promise<SearchResponse> {
|
||
const queryParams = new URLSearchParams();
|
||
queryParams.append('q', query);
|
||
if (options.limit) queryParams.append('limit', options.limit.toString());
|
||
if (options.offset) queryParams.append('offset', options.offset.toString());
|
||
if (options.modelType) queryParams.append('type', options.modelType);
|
||
return this.request(`/api/v1/search/?${queryParams.toString()}`);
|
||
}
|
||
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
async updateCurrentUserProfile(data: Record<string, any>): Promise<any> {
|
||
const hasFile = Object.values(data).some((value) => value instanceof File);
|
||
if (!hasFile) {
|
||
return this.request('/api/v1/employees/me/', {
|
||
method: 'PATCH',
|
||
body: JSON.stringify(data),
|
||
});
|
||
}
|
||
|
||
const formData = new FormData();
|
||
Object.keys(data).forEach((key) => {
|
||
if (Array.isArray(data[key])) {
|
||
data[key].forEach((value: unknown) => {
|
||
if (value !== null && value !== undefined) formData.append(key, String(value));
|
||
});
|
||
} else if (data[key] instanceof File) {
|
||
formData.append(key, data[key]);
|
||
} else if (data[key] !== null && data[key] !== undefined) {
|
||
formData.append(key, data[key]);
|
||
}
|
||
});
|
||
return this.request('/api/v1/employees/me/', {
|
||
method: 'PATCH',
|
||
body: formData,
|
||
timeoutMs: 120_000,
|
||
});
|
||
}
|
||
}
|