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>
This commit is contained in:
Cal Corum 2026-01-30 11:11:11 -06:00
parent f63e8be600
commit 0720084cb1
5 changed files with 595 additions and 3 deletions

View File

@ -6,7 +6,7 @@
"created": "2026-01-30", "created": "2026-01-30",
"lastUpdated": "2026-01-30", "lastUpdated": "2026-01-30",
"totalTasks": 8, "totalTasks": 8,
"completedTasks": 5, "completedTasks": 6,
"status": "in_progress" "status": "in_progress"
}, },
"tasks": [ "tasks": [
@ -101,8 +101,8 @@
"description": "HTTP client with auth token injection and refresh", "description": "HTTP client with auth token injection and refresh",
"category": "api", "category": "api",
"priority": 5, "priority": 5,
"completed": false, "completed": true,
"tested": false, "tested": true,
"dependencies": ["F0-001", "F0-004"], "dependencies": ["F0-001", "F0-004"],
"files": [ "files": [
{"path": "src/api/client.ts", "status": "create"}, {"path": "src/api/client.ts", "status": "create"},

View File

@ -0,0 +1,304 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useAuthStore } from '@/stores/auth'
import { apiClient } from './client'
import { ApiError } from './types'
// Mock fetch globally
const mockFetch = vi.fn()
vi.stubGlobal('fetch', mockFetch)
describe('apiClient', () => {
beforeEach(() => {
setActivePinia(createPinia())
mockFetch.mockReset()
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('get', () => {
it('makes GET request to correct URL', async () => {
/**
* Test that GET requests are sent to the correct URL.
*
* The API client must correctly build URLs from the base URL
* and path, which is fundamental to all API operations.
*/
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ id: '1', name: 'Test' }),
})
const result = await apiClient.get('/api/users/me')
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining('/api/users/me'),
expect.objectContaining({ method: 'GET' })
)
expect(result).toEqual({ id: '1', name: 'Test' })
})
it('adds query parameters to URL', async () => {
/**
* Test that query parameters are correctly appended.
*
* Many API endpoints accept query parameters for filtering,
* pagination, etc. These must be properly encoded.
*/
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ items: [] }),
})
await apiClient.get('/api/cards', { params: { page: 1, limit: 20 } })
const calledUrl = mockFetch.mock.calls[0][0] as string
expect(calledUrl).toContain('page=1')
expect(calledUrl).toContain('limit=20')
})
it('skips undefined query parameters', async () => {
/**
* Test that undefined parameters are not included in URL.
*
* Optional parameters should be omitted entirely rather than
* being sent as "undefined" strings.
*/
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({}),
})
await apiClient.get('/api/cards', {
params: { page: 1, search: undefined },
})
const calledUrl = mockFetch.mock.calls[0][0] as string
expect(calledUrl).toContain('page=1')
expect(calledUrl).not.toContain('search')
})
})
describe('post', () => {
it('makes POST request with JSON body', async () => {
/**
* Test that POST requests include JSON body.
*
* POST requests typically send data to create resources.
* The body must be JSON serialized.
*/
mockFetch.mockResolvedValueOnce({
ok: true,
status: 201,
json: async () => ({ id: '1' }),
})
const body = { name: 'New Deck', cards: [] }
await apiClient.post('/api/decks', body)
expect(mockFetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
method: 'POST',
body: JSON.stringify(body),
})
)
})
})
describe('authentication', () => {
it('adds Authorization header when authenticated', async () => {
/**
* Test that auth header is automatically added.
*
* Authenticated requests must include the Bearer token
* so the backend can identify the user.
*/
const auth = useAuthStore()
auth.setTokens({
accessToken: 'test-token',
refreshToken: 'refresh-token',
expiresAt: Date.now() + 3600000,
})
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({}),
})
await apiClient.get('/api/users/me')
expect(mockFetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'Bearer test-token',
}),
})
)
})
it('skips auth header when skipAuth is true', async () => {
/**
* Test that skipAuth option prevents auth header.
*
* Some endpoints (like login/register) don't need auth
* and should not send the token.
*/
const auth = useAuthStore()
auth.setTokens({
accessToken: 'test-token',
refreshToken: 'refresh-token',
expiresAt: Date.now() + 3600000,
})
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({}),
})
await apiClient.get('/api/public/health', { skipAuth: true })
const calledOptions = mockFetch.mock.calls[0][1] as RequestInit
expect(calledOptions.headers).not.toHaveProperty('Authorization')
})
it('does not add auth header when not authenticated', async () => {
/**
* Test that unauthenticated requests have no auth header.
*
* Without a token, there's nothing to send.
*/
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({}),
})
await apiClient.get('/api/public/status')
const calledOptions = mockFetch.mock.calls[0][1] as RequestInit
const headers = calledOptions.headers as Record<string, string>
expect(headers['Authorization']).toBeUndefined()
})
})
describe('error handling', () => {
it('throws ApiError on non-2xx response', async () => {
/**
* Test that API errors are properly thrown.
*
* Non-successful responses should throw ApiError with
* status code and message for proper error handling.
*/
mockFetch.mockResolvedValueOnce({
ok: false,
status: 404,
statusText: 'Not Found',
json: async () => ({ detail: 'Deck not found' }),
})
try {
await apiClient.get('/api/decks/999')
expect.fail('Should have thrown')
} catch (e) {
expect(e).toBeInstanceOf(ApiError)
const error = e as ApiError
expect(error.status).toBe(404)
expect(error.detail).toBe('Deck not found')
expect(error.isNotFound).toBe(true)
}
})
it('handles non-JSON error responses', async () => {
/**
* Test that non-JSON errors are handled gracefully.
*
* Some server errors may return HTML or plain text.
* The client should still create a proper ApiError.
*/
mockFetch.mockResolvedValueOnce({
ok: false,
status: 500,
statusText: 'Internal Server Error',
json: async () => {
throw new Error('Not JSON')
},
})
await expect(apiClient.get('/api/crash')).rejects.toThrow(ApiError)
})
it('handles 204 No Content responses', async () => {
/**
* Test that 204 responses return undefined.
*
* DELETE and some PUT/PATCH endpoints return 204 with no body.
* The client should handle this without trying to parse JSON.
*/
mockFetch.mockResolvedValueOnce({
ok: true,
status: 204,
})
const result = await apiClient.delete('/api/decks/1')
expect(result).toBeUndefined()
})
})
describe('token refresh', () => {
it('retries request after successful token refresh on 401', async () => {
/**
* Test that 401 triggers token refresh and retry.
*
* When the access token expires, the client should automatically
* refresh it and retry the failed request transparently.
*/
const auth = useAuthStore()
auth.setTokens({
accessToken: 'expired-token',
refreshToken: 'valid-refresh-token',
expiresAt: Date.now() + 3600000,
})
// First call returns 401
mockFetch.mockResolvedValueOnce({
ok: false,
status: 401,
statusText: 'Unauthorized',
json: async () => ({ detail: 'Token expired' }),
})
// Refresh token call succeeds
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
access_token: 'new-token',
expires_in: 3600,
}),
})
// Retry succeeds
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ id: '1', name: 'Test User' }),
})
const result = await apiClient.get('/api/users/me')
expect(mockFetch).toHaveBeenCalledTimes(3)
expect(result).toEqual({ id: '1', name: 'Test User' })
})
})
})

177
frontend/src/api/client.ts Normal file
View File

@ -0,0 +1,177 @@
/**
* 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

11
frontend/src/api/index.ts Normal file
View File

@ -0,0 +1,11 @@
/**
* API module exports.
*/
export { apiClient, default } from './client'
export { ApiError } from './types'
export type {
RequestOptions,
ApiResponse,
PaginatedResponse,
ErrorResponse,
} from './types'

100
frontend/src/api/types.ts Normal file
View File

@ -0,0 +1,100 @@
/**
* API client types and error classes.
*/
/**
* Custom error class for API errors.
*
* Provides structured error information including HTTP status code,
* error message, and optional detail from the backend.
*/
export class ApiError extends Error {
constructor(
public readonly status: number,
public readonly statusText: string,
public readonly detail?: string,
public readonly code?: string
) {
super(detail || statusText)
this.name = 'ApiError'
}
/**
* Check if this is an authentication error (401).
*/
get isUnauthorized(): boolean {
return this.status === 401
}
/**
* Check if this is a forbidden error (403).
*/
get isForbidden(): boolean {
return this.status === 403
}
/**
* Check if this is a not found error (404).
*/
get isNotFound(): boolean {
return this.status === 404
}
/**
* Check if this is a validation error (422).
*/
get isValidationError(): boolean {
return this.status === 422
}
/**
* Check if this is a server error (5xx).
*/
get isServerError(): boolean {
return this.status >= 500
}
}
/**
* Options for API requests.
*/
export interface RequestOptions {
/** Skip automatic auth header injection */
skipAuth?: boolean
/** Custom headers to merge with defaults */
headers?: Record<string, string>
/** Request body (will be JSON serialized) */
body?: unknown
/** Query parameters */
params?: Record<string, string | number | boolean | undefined>
/** AbortSignal for request cancellation */
signal?: AbortSignal
}
/**
* Standard API response wrapper from backend.
*/
export interface ApiResponse<T> {
data: T
message?: string
}
/**
* Paginated response from backend.
*/
export interface PaginatedResponse<T> {
items: T[]
total: number
page: number
pageSize: number
totalPages: number
}
/**
* Backend error response shape.
*/
export interface ErrorResponse {
detail?: string
message?: string
code?: string
}