Files
Sharang Parnerkar e07e1de6c9 refactor(admin): split api-client.ts (885 LOC) and endpoints.ts (1262 LOC) into focused modules
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>
2026-04-10 19:17:38 +02:00

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
}