mantimon-tcg/frontend/src/api/client.ts
Cal Corum 0720084cb1 Add typed API client with auth and error handling (F0-005)
- Create ApiError class with status helpers (isNotFound, etc.)
- Create typed fetch wrapper with get/post/put/patch/delete methods
- Auto-inject Authorization header from auth store
- Handle 401 with automatic token refresh and retry
- Add query parameter support and proper URL building
- Add comprehensive tests (11 tests)

Phase F0 progress: 6/8 tasks complete

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 11:11:11 -06:00

178 lines
4.4 KiB
TypeScript

/**
* HTTP API client with authentication and error handling.
*
* Provides typed fetch wrapper that automatically:
* - Injects Authorization header from auth store
* - Refreshes tokens on 401 responses
* - Parses JSON responses
* - Throws typed ApiError on failures
*/
import { config } from '@/config'
import { useAuthStore } from '@/stores/auth'
import { ApiError } from './types'
import type { RequestOptions, ErrorResponse } from './types'
/**
* Build URL with query parameters.
*/
function buildUrl(path: string, params?: Record<string, string | number | boolean | undefined>): string {
const base = config.apiBaseUrl.replace(/\/$/, '')
const cleanPath = path.startsWith('/') ? path : `/${path}`
const url = new URL(`${base}${cleanPath}`)
if (params) {
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined) {
url.searchParams.set(key, String(value))
}
})
}
return url.toString()
}
/**
* Parse error response from backend.
*/
async function parseErrorResponse(response: Response): Promise<ApiError> {
let detail: string | undefined
let code: string | undefined
try {
const data: ErrorResponse = await response.json()
detail = data.detail || data.message
code = data.code
} catch {
// Response body is not JSON or empty
}
return new ApiError(response.status, response.statusText, detail, code)
}
/**
* Make an authenticated API request.
*
* @param method - HTTP method
* @param path - API path (e.g., '/api/users/me')
* @param options - Request options
* @returns Parsed JSON response
* @throws ApiError on non-2xx responses
*/
async function request<T>(
method: string,
path: string,
options: RequestOptions = {}
): Promise<T> {
const { skipAuth = false, headers = {}, body, params, signal } = options
const auth = useAuthStore()
// Build headers
const requestHeaders: Record<string, string> = {
'Content-Type': 'application/json',
...headers,
}
// Add auth header if authenticated and not skipped
if (!skipAuth && auth.isAuthenticated) {
const token = await auth.getValidToken()
if (token) {
requestHeaders['Authorization'] = `Bearer ${token}`
}
}
// Make request
const url = buildUrl(path, params)
const response = await fetch(url, {
method,
headers: requestHeaders,
body: body !== undefined ? JSON.stringify(body) : undefined,
signal,
})
// Handle 401 - try to refresh and retry once
if (response.status === 401 && !skipAuth && auth.refreshToken) {
const refreshed = await auth.refreshAccessToken()
if (refreshed) {
// Retry with new token
const newToken = await auth.getValidToken()
if (newToken) {
requestHeaders['Authorization'] = `Bearer ${newToken}`
const retryResponse = await fetch(url, {
method,
headers: requestHeaders,
body: body !== undefined ? JSON.stringify(body) : undefined,
signal,
})
if (!retryResponse.ok) {
throw await parseErrorResponse(retryResponse)
}
// Handle 204 No Content
if (retryResponse.status === 204) {
return undefined as T
}
return retryResponse.json()
}
}
// Refresh failed, throw the original 401
throw await parseErrorResponse(response)
}
// Handle other errors
if (!response.ok) {
throw await parseErrorResponse(response)
}
// Handle 204 No Content
if (response.status === 204) {
return undefined as T
}
return response.json()
}
/**
* API client with typed methods for each HTTP verb.
*/
export const apiClient = {
/**
* Make a GET request.
*/
get<T>(path: string, options?: RequestOptions): Promise<T> {
return request<T>('GET', path, options)
},
/**
* Make a POST request.
*/
post<T>(path: string, body?: unknown, options?: RequestOptions): Promise<T> {
return request<T>('POST', path, { ...options, body })
},
/**
* Make a PUT request.
*/
put<T>(path: string, body?: unknown, options?: RequestOptions): Promise<T> {
return request<T>('PUT', path, { ...options, body })
},
/**
* Make a PATCH request.
*/
patch<T>(path: string, body?: unknown, options?: RequestOptions): Promise<T> {
return request<T>('PATCH', path, { ...options, body })
},
/**
* Make a DELETE request.
*/
delete<T>(path: string, options?: RequestOptions): Promise<T> {
return request<T>('DELETE', path, options)
},
}
export default apiClient