VibecoderMcSwaggins's picture
feat(frontend): React + Vite + NiiVue frontend (replaces Gradio) (#32)
e4daa3b unverified
|
raw
history blame
12.6 kB

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:

  1. Type definitions for API responses
  2. apiClient with getCases() and runSegmentation() methods
  3. useSegmentation React hook for state management
  4. MSW handlers for all API endpoints
  5. 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