Files
sbd2-frontend/src/lib/api/client.ts
JSC 4e50e7e79d
Some checks failed
Frontend CI / lint (push) Failing after 19s
Frontend CI / build (push) Has been skipped
Refactor and enhance UI components across multiple pages
- Improved import organization and formatting in PlaylistsPage, RegisterPage, SoundsPage, SettingsPage, and UsersPage for better readability.
- Added error handling and user feedback with toast notifications in SoundsPage and SettingsPage.
- Enhanced user experience by implementing debounced search functionality in PlaylistsPage and SoundsPage.
- Updated the layout and structure of forms in SettingsPage and UsersPage for better usability.
- Improved accessibility and semantics by ensuring proper labeling and descriptions in forms.
- Fixed minor bugs related to state management and API calls in various components.
2025-08-14 23:51:47 +02:00

228 lines
5.9 KiB
TypeScript

import { AUTH_EVENTS, authEvents } from '../events'
import { API_CONFIG } from './config'
import { NetworkError, TimeoutError, createApiError } from './errors'
import type { ApiClient, ApiRequestConfig, HttpMethod } from './types'
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()