api-client.ts is now a thin delegating class (263 LOC) backed by: - api-client-types.ts (84) — shared types, config, FetchContext - api-client-state.ts (120) — state CRUD + export - api-client-projects.ts (160) — project management - api-client-wiki.ts (116) — wiki knowledge base - api-client-operations.ts (299) — checkpoints, flow, modules, UCCA, import, screening endpoints.ts is now a barrel (25 LOC) aggregating the 4 existing domain files (endpoints-python-core, endpoints-python-gdpr, endpoints-python-ops, endpoints-go). All files stay under the 500-line hard cap. Build verified with `npx next build`. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
264 lines
10 KiB
TypeScript
264 lines
10 KiB
TypeScript
/**
|
|
* SDK API Client
|
|
*
|
|
* Centralized API client for SDK state management with error handling,
|
|
* retry logic, and optimistic locking support.
|
|
*
|
|
* Domain methods are implemented in sibling files and delegated to here:
|
|
* api-client-state.ts — getState, saveState, deleteState, exportState
|
|
* api-client-projects.ts — listProjects … permanentlyDeleteProject
|
|
* api-client-wiki.ts — listWikiCategories … searchWiki
|
|
* api-client-operations.ts — checkpoints, flow, modules, UCCA, import, screening
|
|
*/
|
|
|
|
import { SDKState, CheckpointStatus, ProjectInfo, WikiCategory, WikiArticle, WikiSearchResult } from './types'
|
|
import {
|
|
APIResponse,
|
|
StateResponse,
|
|
SaveStateRequest,
|
|
CheckpointValidationResult,
|
|
APIError,
|
|
FetchContext,
|
|
DEFAULT_BASE_URL,
|
|
DEFAULT_TIMEOUT,
|
|
MAX_RETRIES,
|
|
RETRY_DELAYS,
|
|
} from './api-client-types'
|
|
|
|
// Re-export public types so existing consumers keep working
|
|
export type { APIResponse, StateResponse, SaveStateRequest, CheckpointValidationResult, APIError }
|
|
|
|
// Domain helpers
|
|
import * as stateHelpers from './api-client-state'
|
|
import * as projectHelpers from './api-client-projects'
|
|
import * as wikiHelpers from './api-client-wiki'
|
|
import * as opsHelpers from './api-client-operations'
|
|
|
|
// =============================================================================
|
|
// API CLIENT
|
|
// =============================================================================
|
|
|
|
export class SDKApiClient {
|
|
private baseUrl: string
|
|
private tenantId: string
|
|
private projectId: string | undefined
|
|
private timeout: number
|
|
private abortControllers: Map<string, AbortController> = new Map()
|
|
|
|
constructor(options: {
|
|
baseUrl?: string
|
|
tenantId: string
|
|
projectId?: string
|
|
timeout?: number
|
|
}) {
|
|
this.baseUrl = options.baseUrl || DEFAULT_BASE_URL
|
|
this.tenantId = options.tenantId
|
|
this.projectId = options.projectId
|
|
this.timeout = options.timeout || DEFAULT_TIMEOUT
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Private infrastructure — also exposed via FetchContext to helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
createError(message: string, status?: number, retryable = false): APIError {
|
|
const error = new Error(message) as APIError
|
|
error.status = status
|
|
error.retryable = retryable
|
|
return error
|
|
}
|
|
|
|
async fetchWithTimeout(
|
|
url: string,
|
|
options: RequestInit,
|
|
requestId: string
|
|
): Promise<Response> {
|
|
const controller = new AbortController()
|
|
this.abortControllers.set(requestId, controller)
|
|
|
|
const timeoutId = setTimeout(() => controller.abort(), this.timeout)
|
|
|
|
try {
|
|
const response = await fetch(url, {
|
|
...options,
|
|
signal: controller.signal,
|
|
})
|
|
return response
|
|
} finally {
|
|
clearTimeout(timeoutId)
|
|
this.abortControllers.delete(requestId)
|
|
}
|
|
}
|
|
|
|
async fetchWithRetry<T>(
|
|
url: string,
|
|
options: RequestInit,
|
|
retries = MAX_RETRIES
|
|
): Promise<T> {
|
|
const requestId = `${Date.now()}-${Math.random()}`
|
|
let lastError: Error | null = null
|
|
|
|
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
try {
|
|
const response = await this.fetchWithTimeout(url, options, requestId)
|
|
|
|
if (!response.ok) {
|
|
const errorBody = await response.text()
|
|
let errorMessage = `HTTP ${response.status}`
|
|
|
|
try {
|
|
const errorJson = JSON.parse(errorBody)
|
|
errorMessage = errorJson.error || errorJson.message || errorMessage
|
|
} catch {
|
|
// Keep the HTTP status message
|
|
}
|
|
|
|
// Don't retry client errors (4xx) except for 429 (rate limit)
|
|
const retryable = response.status >= 500 || response.status === 429
|
|
|
|
if (!retryable || attempt === retries) {
|
|
throw this.createError(errorMessage, response.status, retryable)
|
|
}
|
|
} else {
|
|
const data = await response.json()
|
|
return data as T
|
|
}
|
|
} catch (error) {
|
|
lastError = error as Error
|
|
|
|
if (error instanceof Error && error.name === 'AbortError') {
|
|
throw this.createError('Request timeout', 408, true)
|
|
}
|
|
|
|
// Check if it's a retryable error
|
|
const apiError = error as APIError
|
|
if (!apiError.retryable || attempt === retries) {
|
|
throw error
|
|
}
|
|
}
|
|
|
|
// Wait before retrying (exponential backoff)
|
|
if (attempt < retries) {
|
|
await this.sleep(RETRY_DELAYS[attempt] || RETRY_DELAYS[RETRY_DELAYS.length - 1])
|
|
}
|
|
}
|
|
|
|
throw lastError || this.createError('Unknown error', 500, false)
|
|
}
|
|
|
|
private sleep(ms: number): Promise<void> {
|
|
return new Promise(resolve => setTimeout(resolve, ms))
|
|
}
|
|
|
|
/** Build a FetchContext for passing to domain helpers */
|
|
private get ctx(): FetchContext {
|
|
return {
|
|
baseUrl: this.baseUrl,
|
|
tenantId: this.tenantId,
|
|
projectId: this.projectId,
|
|
fetchWithRetry: this.fetchWithRetry.bind(this),
|
|
fetchWithTimeout: this.fetchWithTimeout.bind(this),
|
|
createError: this.createError.bind(this),
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// State Management (api-client-state.ts)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async getState(): Promise<StateResponse | null> { return stateHelpers.getState(this.ctx) }
|
|
async saveState(state: SDKState, version?: number): Promise<StateResponse> { return stateHelpers.saveState(this.ctx, state, version) }
|
|
async deleteState(): Promise<void> { return stateHelpers.deleteState(this.ctx) }
|
|
async exportState(format: 'json' | 'pdf' | 'zip'): Promise<Blob> { return stateHelpers.exportState(this.ctx, format) }
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Checkpoints & Flow (api-client-operations.ts)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async validateCheckpoint(checkpointId: string, data?: unknown): Promise<CheckpointValidationResult> { return opsHelpers.validateCheckpoint(this.ctx, checkpointId, data) }
|
|
async getCheckpoints(): Promise<Record<string, CheckpointStatus>> { return opsHelpers.getCheckpoints(this.ctx) }
|
|
async getFlowState() { return opsHelpers.getFlowState(this.ctx) }
|
|
async navigateFlow(direction: 'next' | 'previous') { return opsHelpers.navigateFlow(this.ctx, direction) }
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Modules, UCCA, Import, Screening, Health (api-client-operations.ts)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async getModules(filters?: Parameters<typeof opsHelpers.getModules>[1]) { return opsHelpers.getModules(this.ctx, filters) }
|
|
async assessUseCase(intake: unknown) { return opsHelpers.assessUseCase(this.ctx, intake) }
|
|
async getAssessments() { return opsHelpers.getAssessments(this.ctx) }
|
|
async getAssessment(id: string) { return opsHelpers.getAssessment(this.ctx, id) }
|
|
async deleteAssessment(id: string) { return opsHelpers.deleteAssessment(this.ctx, id) }
|
|
async analyzeDocument(formData: FormData) { return opsHelpers.analyzeDocument(this.ctx, formData) }
|
|
async scanDependencies(formData: FormData) { return opsHelpers.scanDependencies(this.ctx, formData) }
|
|
async healthCheck() { return opsHelpers.healthCheck(this.ctx) }
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Projects (api-client-projects.ts)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async listProjects(includeArchived = true) { return projectHelpers.listProjects(this.ctx, includeArchived) }
|
|
async createProject(data: Parameters<typeof projectHelpers.createProject>[1]) { return projectHelpers.createProject(this.ctx, data) }
|
|
async updateProject(projectId: string, data: Parameters<typeof projectHelpers.updateProject>[2]) { return projectHelpers.updateProject(this.ctx, projectId, data) }
|
|
async getProject(projectId: string) { return projectHelpers.getProject(this.ctx, projectId) }
|
|
async archiveProject(projectId: string) { return projectHelpers.archiveProject(this.ctx, projectId) }
|
|
async restoreProject(projectId: string) { return projectHelpers.restoreProject(this.ctx, projectId) }
|
|
async permanentlyDeleteProject(projectId: string) { return projectHelpers.permanentlyDeleteProject(this.ctx, projectId) }
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Wiki (api-client-wiki.ts)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async listWikiCategories() { return wikiHelpers.listWikiCategories(this.ctx) }
|
|
async listWikiArticles(categoryId?: string) { return wikiHelpers.listWikiArticles(this.ctx, categoryId) }
|
|
async getWikiArticle(id: string) { return wikiHelpers.getWikiArticle(this.ctx, id) }
|
|
async searchWiki(query: string) { return wikiHelpers.searchWiki(this.ctx, query) }
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Utility
|
|
// ---------------------------------------------------------------------------
|
|
|
|
cancelAllRequests(): void {
|
|
this.abortControllers.forEach(controller => controller.abort())
|
|
this.abortControllers.clear()
|
|
}
|
|
|
|
setTenantId(tenantId: string): void { this.tenantId = tenantId }
|
|
getTenantId(): string { return this.tenantId }
|
|
setProjectId(projectId: string | undefined): void { this.projectId = projectId }
|
|
getProjectId(): string | undefined { return this.projectId }
|
|
}
|
|
|
|
// =============================================================================
|
|
// SINGLETON FACTORY
|
|
// =============================================================================
|
|
|
|
let clientInstance: SDKApiClient | null = null
|
|
|
|
export function getSDKApiClient(tenantId?: string, projectId?: string): SDKApiClient {
|
|
if (!clientInstance && !tenantId) {
|
|
throw new Error('SDKApiClient not initialized. Provide tenantId on first call.')
|
|
}
|
|
|
|
if (!clientInstance && tenantId) {
|
|
clientInstance = new SDKApiClient({ tenantId, projectId })
|
|
}
|
|
|
|
if (tenantId && clientInstance && clientInstance.getTenantId() !== tenantId) {
|
|
clientInstance.setTenantId(tenantId)
|
|
}
|
|
|
|
if (clientInstance) {
|
|
clientInstance.setProjectId(projectId)
|
|
}
|
|
|
|
return clientInstance!
|
|
}
|
|
|
|
export function resetSDKApiClient(): void {
|
|
if (clientInstance) {
|
|
clientInstance.cancelAllRequests()
|
|
}
|
|
clientInstance = null
|
|
}
|