206 lines
5.8 KiB
TypeScript
206 lines
5.8 KiB
TypeScript
import { API_CONFIG } from './config'
|
|
import { createApiError, NetworkError, TimeoutError } from './errors'
|
|
import type { ApiClient, ApiRequestConfig, HttpMethod } from './types'
|
|
import { authEvents, AUTH_EVENTS } from '../events'
|
|
|
|
export class BaseApiClient implements ApiClient {
|
|
private refreshPromise: Promise<void> | null = null
|
|
private baseURL: string
|
|
|
|
constructor(baseURL: string = API_CONFIG.BASE_URL) {
|
|
this.baseURL = baseURL
|
|
}
|
|
|
|
private buildURL(endpoint: string, params?: Record<string, string | number | boolean | undefined>): string {
|
|
let url: URL
|
|
|
|
if (this.baseURL) {
|
|
// Full base URL provided
|
|
url = new URL(endpoint, this.baseURL)
|
|
} else {
|
|
// Use relative URL (for reverse proxy)
|
|
url = new URL(endpoint, window.location.origin)
|
|
}
|
|
|
|
if (params) {
|
|
Object.entries(params).forEach(([key, value]) => {
|
|
if (value !== undefined) {
|
|
url.searchParams.append(key, String(value))
|
|
}
|
|
})
|
|
}
|
|
|
|
return this.baseURL ? url.toString() : url.pathname + url.search
|
|
}
|
|
|
|
private async request<T>(
|
|
method: HttpMethod,
|
|
endpoint: string,
|
|
data?: unknown,
|
|
config: ApiRequestConfig = {}
|
|
): Promise<T> {
|
|
const {
|
|
params,
|
|
skipAuth = false,
|
|
timeout = API_CONFIG.TIMEOUT,
|
|
headers: customHeaders,
|
|
...fetchConfig
|
|
} = config
|
|
|
|
const url = this.buildURL(endpoint, params)
|
|
const headers: Record<string, string> = {
|
|
'Content-Type': 'application/json',
|
|
...(customHeaders as Record<string, string>),
|
|
}
|
|
|
|
const requestConfig: RequestInit = {
|
|
method,
|
|
headers,
|
|
credentials: skipAuth ? 'omit' : 'include',
|
|
...fetchConfig,
|
|
}
|
|
|
|
if (data && ['POST', 'PUT', 'PATCH'].includes(method)) {
|
|
if (data instanceof FormData) {
|
|
// Remove Content-Type header for FormData to let browser set it with boundary
|
|
delete headers['Content-Type']
|
|
requestConfig.body = data
|
|
} else {
|
|
requestConfig.body = JSON.stringify(data)
|
|
}
|
|
}
|
|
|
|
const controller = new AbortController()
|
|
const timeoutId = setTimeout(() => controller.abort(), timeout)
|
|
requestConfig.signal = controller.signal
|
|
|
|
try {
|
|
const response = await fetch(url, requestConfig)
|
|
clearTimeout(timeoutId)
|
|
|
|
if (!response.ok) {
|
|
if (response.status === 401 && !skipAuth) {
|
|
try {
|
|
await this.handleTokenRefresh()
|
|
// Retry the original request
|
|
const retryResponse = await fetch(url, requestConfig)
|
|
|
|
if (!retryResponse.ok) {
|
|
const errorData = await this.safeParseJSON(retryResponse)
|
|
throw createApiError(retryResponse, errorData)
|
|
}
|
|
|
|
return await this.safeParseJSON(retryResponse) as T
|
|
} catch (refreshError) {
|
|
this.handleAuthenticationFailure()
|
|
throw refreshError
|
|
}
|
|
}
|
|
|
|
const errorData = await this.safeParseJSON(response)
|
|
throw createApiError(response, errorData)
|
|
}
|
|
|
|
// Handle empty responses (204 No Content, etc.)
|
|
if (response.status === 204 || response.headers.get('content-length') === '0') {
|
|
return {} as T
|
|
}
|
|
|
|
return await this.safeParseJSON(response) as T
|
|
} catch (error) {
|
|
clearTimeout(timeoutId)
|
|
|
|
if ((error as Error).name === 'AbortError') {
|
|
throw new TimeoutError()
|
|
}
|
|
|
|
if (error instanceof TypeError && error.message.includes('fetch')) {
|
|
throw new NetworkError()
|
|
}
|
|
|
|
throw error
|
|
}
|
|
}
|
|
|
|
private async safeParseJSON(response: Response): Promise<unknown> {
|
|
try {
|
|
const text = await response.text()
|
|
return text ? JSON.parse(text) : {}
|
|
} catch {
|
|
return {}
|
|
}
|
|
}
|
|
|
|
private async handleTokenRefresh(): Promise<void> {
|
|
if (this.refreshPromise) {
|
|
await this.refreshPromise
|
|
return
|
|
}
|
|
|
|
this.refreshPromise = this.performTokenRefresh()
|
|
|
|
try {
|
|
await this.refreshPromise
|
|
} finally {
|
|
this.refreshPromise = null
|
|
}
|
|
}
|
|
|
|
private async performTokenRefresh(): Promise<void> {
|
|
const response = await fetch(`${this.baseURL}${API_CONFIG.ENDPOINTS.AUTH.REFRESH}`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
credentials: 'include',
|
|
})
|
|
|
|
if (!response.ok) {
|
|
throw createApiError(response, await this.safeParseJSON(response))
|
|
}
|
|
|
|
// Emit token refresh event for socket reconnection (reactive refresh)
|
|
authEvents.emit(AUTH_EVENTS.TOKEN_REFRESHED)
|
|
}
|
|
|
|
private handleAuthenticationFailure(): void {
|
|
// Only redirect if we're not already on auth pages to prevent infinite loops
|
|
const currentPath = window.location.pathname
|
|
const authPaths = ['/login', '/register', '/auth/callback']
|
|
|
|
if (!authPaths.includes(currentPath)) {
|
|
window.location.href = '/login'
|
|
}
|
|
}
|
|
|
|
// Public API methods
|
|
async get<T>(endpoint: string, config?: ApiRequestConfig): Promise<T> {
|
|
return this.request<T>('GET', endpoint, undefined, config)
|
|
}
|
|
|
|
async post<T>(endpoint: string, data?: unknown, config?: ApiRequestConfig): Promise<T> {
|
|
return this.request<T>('POST', endpoint, data, config)
|
|
}
|
|
|
|
async put<T>(endpoint: string, data?: unknown, config?: ApiRequestConfig): Promise<T> {
|
|
return this.request<T>('PUT', endpoint, data, config)
|
|
}
|
|
|
|
async patch<T>(endpoint: string, data?: unknown, config?: ApiRequestConfig): Promise<T> {
|
|
return this.request<T>('PATCH', endpoint, data, config)
|
|
}
|
|
|
|
async delete<T>(endpoint: string, config?: ApiRequestConfig): Promise<T> {
|
|
return this.request<T>('DELETE', endpoint, undefined, config)
|
|
}
|
|
|
|
// Utility methods
|
|
setBaseURL(baseURL: string): void {
|
|
this.baseURL = baseURL
|
|
}
|
|
|
|
getBaseURL(): string {
|
|
return this.baseURL
|
|
}
|
|
}
|
|
|
|
// Default API client instance
|
|
export const apiClient = new BaseApiClient() |