Spec 37.2: API Layer
Status: READY FOR IMPLEMENTATION Phase: 2 of 5 Depends On: Spec 37.1 (Foundation Components) Goal: TDD implementation of API client and useSegmentation hook
Deliverables
By the end of this phase, you will have:
- Type definitions for API responses
apiClientwithgetCases()andrunSegmentation()methodsuseSegmentationReact hook for state management- MSW handlers for all API endpoints
- Error handling tests
Step 1: Type Definitions
Create src/types/index.ts:
export interface Metrics {
caseId: string
diceScore: number | null
volumeMl: number | null
elapsedSeconds: number
}
export interface SegmentationResult {
dwiUrl: string
predictionUrl: string
metrics: Metrics
}
export interface CasesResponse {
cases: string[]
}
export interface SegmentResponse {
caseId: string
diceScore: number | null
volumeMl: number | null
elapsedSeconds: number
dwiUrl: string
predictionUrl: string
}
Step 2: Test Fixtures
Create src/test/fixtures.ts:
import type { SegmentationResult, CasesResponse } from '../types'
export const mockCases: string[] = [
'sub-stroke0001',
'sub-stroke0002',
'sub-stroke0003',
]
export const mockCasesResponse: CasesResponse = {
cases: mockCases,
}
export const mockSegmentationResult: SegmentationResult = {
dwiUrl: 'http://localhost:7860/files/dwi.nii.gz',
predictionUrl: 'http://localhost:7860/files/prediction.nii.gz',
metrics: {
caseId: 'sub-stroke0001',
diceScore: 0.847,
volumeMl: 15.32,
elapsedSeconds: 12.5,
},
}
Step 3: Enhanced MSW Handlers
Update src/mocks/handlers.ts:
import { http, HttpResponse, delay } from 'msw'
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:7860'
export const handlers = [
http.get(`${API_BASE}/api/cases`, async () => {
await delay(100)
return HttpResponse.json({
cases: ['sub-stroke0001', 'sub-stroke0002', 'sub-stroke0003'],
})
}),
http.post(`${API_BASE}/api/segment`, async ({ request }) => {
const body = (await request.json()) as { case_id: string; fast_mode?: boolean }
await delay(200)
return HttpResponse.json({
caseId: body.case_id,
diceScore: 0.847,
volumeMl: 15.32,
// Reflect fast_mode in response - slower when fast_mode=false
elapsedSeconds: body.fast_mode === false ? 45.0 : 12.5,
dwiUrl: `${API_BASE}/files/dwi.nii.gz`,
predictionUrl: `${API_BASE}/files/prediction.nii.gz`,
})
}),
]
// Error handlers for testing error states
export const errorHandlers = {
casesServerError: http.get(`${API_BASE}/api/cases`, () => {
return HttpResponse.json(
{ detail: 'Internal server error' },
{ status: 500 }
)
}),
casesNetworkError: http.get(`${API_BASE}/api/cases`, () => {
return HttpResponse.error()
}),
segmentServerError: http.post(`${API_BASE}/api/segment`, () => {
return HttpResponse.json(
{ detail: 'Segmentation failed: out of memory' },
{ status: 500 }
)
}),
segmentTimeout: http.post(`${API_BASE}/api/segment`, async () => {
await delay(30000)
return HttpResponse.json({ detail: 'Timeout' }, { status: 504 })
}),
}
Step 4: API Client
Test First
Create src/api/__tests__/client.test.ts:
import { describe, it, expect } from 'vitest'
import { server } from '../../mocks/server'
import { errorHandlers } from '../../mocks/handlers'
import { apiClient } from '../client'
describe('apiClient', () => {
describe('getCases', () => {
it('returns list of case IDs', async () => {
const result = await apiClient.getCases()
expect(result.cases).toHaveLength(3)
expect(result.cases).toContain('sub-stroke0001')
})
it('throws ApiError on server error', async () => {
server.use(errorHandlers.casesServerError)
await expect(apiClient.getCases()).rejects.toThrow(/failed to fetch cases/i)
})
it('throws ApiError on network error', async () => {
server.use(errorHandlers.casesNetworkError)
await expect(apiClient.getCases()).rejects.toThrow()
})
})
describe('runSegmentation', () => {
it('returns segmentation result', async () => {
const result = await apiClient.runSegmentation('sub-stroke0001')
expect(result.caseId).toBe('sub-stroke0001')
expect(result.diceScore).toBe(0.847)
expect(result.volumeMl).toBe(15.32)
expect(result.dwiUrl).toContain('dwi.nii.gz')
expect(result.predictionUrl).toContain('prediction.nii.gz')
})
it('sends fast_mode parameter', async () => {
const result = await apiClient.runSegmentation('sub-stroke0001', false)
expect(result).toBeDefined()
})
it('defaults fast_mode to true', async () => {
const result = await apiClient.runSegmentation('sub-stroke0001')
expect(result).toBeDefined()
})
it('throws ApiError on server error', async () => {
server.use(errorHandlers.segmentServerError)
await expect(
apiClient.runSegmentation('sub-stroke0001')
).rejects.toThrow(/segmentation failed/i)
})
})
})
Implementation
Create src/api/client.ts:
import type { CasesResponse, SegmentResponse } from '../types'
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:7860'
export class ApiError extends Error {
constructor(
message: string,
public status: number,
public detail?: string
) {
super(message)
this.name = 'ApiError'
}
}
class ApiClient {
private baseUrl: string
constructor(baseUrl: string) {
this.baseUrl = baseUrl
}
async getCases(): Promise<CasesResponse> {
const response = await fetch(`${this.baseUrl}/api/cases`)
if (!response.ok) {
const error = await response.json().catch(() => ({}))
throw new ApiError(
`Failed to fetch cases: ${response.statusText}`,
response.status,
error.detail
)
}
return response.json()
}
async runSegmentation(
caseId: string,
fastMode: boolean = true
): Promise<SegmentResponse> {
const response = await fetch(`${this.baseUrl}/api/segment`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
case_id: caseId,
fast_mode: fastMode,
}),
})
if (!response.ok) {
const error = await response.json().catch(() => ({}))
throw new ApiError(
`Segmentation failed: ${error.detail || response.statusText}`,
response.status,
error.detail
)
}
return response.json()
}
}
export const apiClient = new ApiClient(API_BASE)
Verify
npm test -- client
# Expected: 7 tests passing
Step 5: useSegmentation Hook
Test First
Create src/hooks/__tests__/useSegmentation.test.tsx:
import { describe, it, expect } from 'vitest'
import { renderHook, waitFor, act } from '@testing-library/react'
import { server } from '../../mocks/server'
import { errorHandlers } from '../../mocks/handlers'
import { useSegmentation } from '../useSegmentation'
describe('useSegmentation', () => {
it('starts with null result and not loading', () => {
const { result } = renderHook(() => useSegmentation())
expect(result.current.result).toBeNull()
expect(result.current.isLoading).toBe(false)
expect(result.current.error).toBeNull()
})
it('sets loading state during segmentation', async () => {
const { result } = renderHook(() => useSegmentation())
act(() => {
result.current.runSegmentation('sub-stroke0001')
})
expect(result.current.isLoading).toBe(true)
await waitFor(() => {
expect(result.current.isLoading).toBe(false)
})
})
it('returns result on success', async () => {
const { result } = renderHook(() => useSegmentation())
await act(async () => {
await result.current.runSegmentation('sub-stroke0001')
})
expect(result.current.result).not.toBeNull()
expect(result.current.result?.metrics.caseId).toBe('sub-stroke0001')
expect(result.current.result?.metrics.diceScore).toBe(0.847)
expect(result.current.result?.dwiUrl).toContain('dwi.nii.gz')
})
it('sets error on failure', async () => {
server.use(errorHandlers.segmentServerError)
const { result } = renderHook(() => useSegmentation())
await act(async () => {
await result.current.runSegmentation('sub-stroke0001')
})
expect(result.current.error).toMatch(/segmentation failed/i)
expect(result.current.result).toBeNull()
})
it('clears previous error on new request', async () => {
server.use(errorHandlers.segmentServerError)
const { result } = renderHook(() => useSegmentation())
// First request fails
await act(async () => {
await result.current.runSegmentation('sub-stroke0001')
})
expect(result.current.error).not.toBeNull()
// Reset to success handler
server.resetHandlers()
// Second request succeeds
await act(async () => {
await result.current.runSegmentation('sub-stroke0001')
})
expect(result.current.error).toBeNull()
expect(result.current.result).not.toBeNull()
})
it('clears previous result on new request', async () => {
const { result } = renderHook(() => useSegmentation())
// First request
await act(async () => {
await result.current.runSegmentation('sub-stroke0001')
})
expect(result.current.result).not.toBeNull()
// Start second request - result should clear while loading
act(() => {
result.current.runSegmentation('sub-stroke0002')
})
// While loading, previous result is still available
// (or you could clear it - depends on UX preference)
expect(result.current.isLoading).toBe(true)
})
})
Implementation
Create src/hooks/useSegmentation.ts:
import { useState, useCallback } from 'react'
import { apiClient } from '../api/client'
import type { SegmentationResult } from '../types'
export function useSegmentation() {
const [result, setResult] = useState<SegmentationResult | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const runSegmentation = useCallback(async (caseId: string, fastMode = true) => {
setIsLoading(true)
setError(null)
try {
const data = await apiClient.runSegmentation(caseId, fastMode)
setResult({
dwiUrl: data.dwiUrl,
predictionUrl: data.predictionUrl,
metrics: {
caseId: data.caseId,
diceScore: data.diceScore,
volumeMl: data.volumeMl,
elapsedSeconds: data.elapsedSeconds,
},
})
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error'
setError(message)
setResult(null)
} finally {
setIsLoading(false)
}
}, [])
return { result, isLoading, error, runSegmentation }
}
Verify
npm test -- useSegmentation
# Expected: 6 tests passing
Step 6: Create Index Export
Create src/hooks/index.ts:
export { useSegmentation } from './useSegmentation'
Create src/api/index.ts:
export { apiClient, ApiError } from './client'
Verification Checklist
# Run all tests
npm test
# Expected output:
# - client.test.ts: 7 tests passing
# - useSegmentation.test.tsx: 6 tests passing
# Total: ~25+ tests passing
- API client handles success responses
- API client handles error responses
- Hook manages loading state correctly
- Hook manages error state correctly
- Hook transforms API response to SegmentationResult
File Structure After This Phase
frontend/src/
βββ api/
β βββ __tests__/
β β βββ client.test.ts
β βββ client.ts
β βββ index.ts
βββ hooks/
β βββ __tests__/
β β βββ useSegmentation.test.tsx
β βββ useSegmentation.ts
β βββ index.ts
βββ types/
β βββ index.ts
βββ test/
β βββ setup.ts
β βββ fixtures.ts
βββ mocks/
β βββ handlers.ts (updated)
β βββ server.ts
βββ components/
β βββ ...
βββ ...
Next Phase
Once verification passes, proceed to Spec 37.3: Interactive Components