- 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>
178 lines
4.4 KiB
TypeScript
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
|