Corp/frontend/src/lib/api/client.ts

420 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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> {
// This endpoint deliberately does not depend on JWT authentication:
// the browser SSO cookie must still be cleared when access/refresh
// tokens have expired or were removed by another tab.
await this.unauthenticatedRequest<void>('/api/auth/browser-session/logout/', {
method: 'POST',
credentials: 'include',
});
}
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,
});
}
}