/** * SDK API Client * * Centralized API client for SDK state management with error handling, * retry logic, and optimistic locking support. */ import { SDKState, CheckpointStatus, ProjectInfo, WikiCategory, WikiArticle, WikiSearchResult } from './types' // ============================================================================= // TYPES // ============================================================================= export interface APIResponse { success: boolean data?: T error?: string version?: number lastModified?: string } export interface StateResponse { tenantId: string state: SDKState version: number lastModified: string } export interface SaveStateRequest { tenantId: string state: SDKState version?: number // For optimistic locking } export interface CheckpointValidationResult { checkpointId: string passed: boolean errors: Array<{ ruleId: string field: string message: string severity: 'ERROR' | 'WARNING' | 'INFO' }> warnings: Array<{ ruleId: string field: string message: string severity: 'ERROR' | 'WARNING' | 'INFO' }> validatedAt: string validatedBy: string } export interface APIError extends Error { status?: number code?: string retryable: boolean } // ============================================================================= // CONFIGURATION // ============================================================================= const DEFAULT_BASE_URL = '/api/sdk/v1' const DEFAULT_TIMEOUT = 30000 // 30 seconds const MAX_RETRIES = 3 const RETRY_DELAYS = [1000, 2000, 4000] // Exponential backoff // ============================================================================= // API CLIENT // ============================================================================= export class SDKApiClient { private baseUrl: string private tenantId: string private projectId: string | undefined private timeout: number private abortControllers: Map = 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 Methods // --------------------------------------------------------------------------- private createError(message: string, status?: number, retryable = false): APIError { const error = new Error(message) as APIError error.status = status error.retryable = retryable return error } private async fetchWithTimeout( url: string, options: RequestInit, requestId: string ): Promise { 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) } } private async fetchWithRetry( url: string, options: RequestInit, retries = MAX_RETRIES ): Promise { 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 { return new Promise(resolve => setTimeout(resolve, ms)) } // --------------------------------------------------------------------------- // Public Methods - State Management // --------------------------------------------------------------------------- /** * Load SDK state for the current tenant */ async getState(): Promise { try { const params = new URLSearchParams({ tenantId: this.tenantId }) if (this.projectId) params.set('projectId', this.projectId) const response = await this.fetchWithRetry>( `${this.baseUrl}/state?${params.toString()}`, { method: 'GET', headers: { 'Content-Type': 'application/json', }, } ) if (response.success && response.data) { return response.data } return null } catch (error) { const apiError = error as APIError // 404 means no state exists yet - that's okay if (apiError.status === 404) { return null } throw error } } /** * Save SDK state for the current tenant * Supports optimistic locking via version parameter */ async saveState(state: SDKState, version?: number): Promise { const response = await this.fetchWithRetry>( `${this.baseUrl}/state`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(version !== undefined && { 'If-Match': String(version) }), }, body: JSON.stringify({ tenantId: this.tenantId, projectId: this.projectId, state, version, }), } ) if (!response.success) { throw this.createError(response.error || 'Failed to save state', 500, true) } return response.data! } /** * Delete SDK state for the current tenant */ async deleteState(): Promise { const params = new URLSearchParams({ tenantId: this.tenantId }) if (this.projectId) params.set('projectId', this.projectId) await this.fetchWithRetry>( `${this.baseUrl}/state?${params.toString()}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json', }, } ) } // --------------------------------------------------------------------------- // Public Methods - Checkpoint Validation // --------------------------------------------------------------------------- /** * Validate a specific checkpoint */ async validateCheckpoint( checkpointId: string, data?: unknown ): Promise { const response = await this.fetchWithRetry>( `${this.baseUrl}/checkpoints/validate`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ tenantId: this.tenantId, checkpointId, data, }), } ) if (!response.success || !response.data) { throw this.createError(response.error || 'Checkpoint validation failed', 500, true) } return response.data } /** * Get all checkpoint statuses */ async getCheckpoints(): Promise> { const response = await this.fetchWithRetry>>( `${this.baseUrl}/checkpoints?tenantId=${encodeURIComponent(this.tenantId)}`, { method: 'GET', headers: { 'Content-Type': 'application/json', }, } ) return response.data || {} } // --------------------------------------------------------------------------- // Public Methods - Flow Navigation // --------------------------------------------------------------------------- /** * Get current flow state */ async getFlowState(): Promise<{ currentStep: string currentPhase: 1 | 2 completedSteps: string[] suggestions: Array<{ stepId: string; reason: string }> }> { const response = await this.fetchWithRetry }>>( `${this.baseUrl}/flow?tenantId=${encodeURIComponent(this.tenantId)}`, { method: 'GET', headers: { 'Content-Type': 'application/json', }, } ) if (!response.data) { throw this.createError('Failed to get flow state', 500, true) } return response.data } /** * Navigate to next/previous step */ async navigateFlow(direction: 'next' | 'previous'): Promise<{ stepId: string phase: 1 | 2 }> { const response = await this.fetchWithRetry>( `${this.baseUrl}/flow`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ tenantId: this.tenantId, direction, }), } ) if (!response.data) { throw this.createError('Failed to navigate flow', 500, true) } return response.data } // --------------------------------------------------------------------------- // Public Methods - Modules // --------------------------------------------------------------------------- /** * Get available compliance modules from backend */ async getModules(filters?: { serviceType?: string criticality?: string processesPii?: boolean aiComponents?: boolean }): Promise<{ modules: unknown[]; total: number }> { const params = new URLSearchParams() if (filters?.serviceType) params.set('service_type', filters.serviceType) if (filters?.criticality) params.set('criticality', filters.criticality) if (filters?.processesPii !== undefined) params.set('processes_pii', String(filters.processesPii)) if (filters?.aiComponents !== undefined) params.set('ai_components', String(filters.aiComponents)) const queryString = params.toString() const url = `${this.baseUrl}/modules${queryString ? `?${queryString}` : ''}` const response = await this.fetchWithRetry<{ modules: unknown[]; total: number }>( url, { method: 'GET', headers: { 'Content-Type': 'application/json' }, } ) return response } // --------------------------------------------------------------------------- // Public Methods - UCCA (Use Case Compliance Assessment) // --------------------------------------------------------------------------- /** * Assess a use case */ async assessUseCase(intake: unknown): Promise { const response = await this.fetchWithRetry>( `${this.baseUrl}/ucca/assess`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Tenant-ID': this.tenantId, }, body: JSON.stringify(intake), } ) return response } /** * Get all assessments */ async getAssessments(): Promise { const response = await this.fetchWithRetry>( `${this.baseUrl}/ucca/assessments?tenantId=${encodeURIComponent(this.tenantId)}`, { method: 'GET', headers: { 'Content-Type': 'application/json', 'X-Tenant-ID': this.tenantId, }, } ) return response.data || [] } /** * Get a single assessment */ async getAssessment(id: string): Promise { const response = await this.fetchWithRetry>( `${this.baseUrl}/ucca/assessments/${id}`, { method: 'GET', headers: { 'Content-Type': 'application/json', 'X-Tenant-ID': this.tenantId, }, } ) return response.data } /** * Delete an assessment */ async deleteAssessment(id: string): Promise { await this.fetchWithRetry>( `${this.baseUrl}/ucca/assessments/${id}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json', 'X-Tenant-ID': this.tenantId, }, } ) } // --------------------------------------------------------------------------- // Public Methods - Document Import // --------------------------------------------------------------------------- /** * Analyze an uploaded document */ async analyzeDocument(formData: FormData): Promise { const response = await this.fetchWithRetry>( `${this.baseUrl}/import/analyze`, { method: 'POST', headers: { 'X-Tenant-ID': this.tenantId, }, body: formData, } ) return response.data } // --------------------------------------------------------------------------- // Public Methods - System Screening // --------------------------------------------------------------------------- /** * Scan a dependency file (package-lock.json, requirements.txt, etc.) */ async scanDependencies(formData: FormData): Promise { const response = await this.fetchWithRetry>( `${this.baseUrl}/screening/scan`, { method: 'POST', headers: { 'X-Tenant-ID': this.tenantId, }, body: formData, } ) return response.data } // --------------------------------------------------------------------------- // Public Methods - Export // --------------------------------------------------------------------------- /** * Export SDK state in various formats */ async exportState(format: 'json' | 'pdf' | 'zip'): Promise { const response = await this.fetchWithTimeout( `${this.baseUrl}/export?tenantId=${encodeURIComponent(this.tenantId)}&format=${format}`, { method: 'GET', headers: { 'Accept': format === 'json' ? 'application/json' : format === 'pdf' ? 'application/pdf' : 'application/zip', }, }, `export-${Date.now()}` ) if (!response.ok) { throw this.createError(`Export failed: ${response.statusText}`, response.status, true) } return response.blob() } // --------------------------------------------------------------------------- // Public Methods - Utility // --------------------------------------------------------------------------- /** * Cancel all pending requests */ cancelAllRequests(): void { this.abortControllers.forEach(controller => controller.abort()) this.abortControllers.clear() } /** * Update tenant ID (useful when switching contexts) */ setTenantId(tenantId: string): void { this.tenantId = tenantId } /** * Get current tenant ID */ getTenantId(): string { return this.tenantId } /** * Set project ID for multi-project support */ setProjectId(projectId: string | undefined): void { this.projectId = projectId } /** * Get current project ID */ getProjectId(): string | undefined { return this.projectId } // --------------------------------------------------------------------------- // Public Methods - Project Management // --------------------------------------------------------------------------- /** * List all projects for the current tenant */ async listProjects(includeArchived = true): Promise<{ projects: ProjectInfo[]; total: number }> { const response = await this.fetchWithRetry<{ projects: ProjectInfo[]; total: number }>( `${this.baseUrl}/projects?tenant_id=${encodeURIComponent(this.tenantId)}&include_archived=${includeArchived}`, { method: 'GET', headers: { 'Content-Type': 'application/json', 'X-Tenant-ID': this.tenantId, }, } ) return response } /** * Create a new project */ async createProject(data: { name: string description?: string customer_type?: string copy_from_project_id?: string }): Promise { const response = await this.fetchWithRetry( `${this.baseUrl}/projects?tenant_id=${encodeURIComponent(this.tenantId)}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Tenant-ID': this.tenantId, }, body: JSON.stringify({ ...data, tenant_id: this.tenantId, }), } ) return response } /** * Update an existing project */ async updateProject(projectId: string, data: { name?: string description?: string }): Promise { const response = await this.fetchWithRetry( `${this.baseUrl}/projects/${projectId}?tenant_id=${encodeURIComponent(this.tenantId)}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', 'X-Tenant-ID': this.tenantId, }, body: JSON.stringify({ ...data, tenant_id: this.tenantId, }), } ) return response } /** * Get a single project by ID */ async getProject(projectId: string): Promise { const response = await this.fetchWithRetry( `${this.baseUrl}/projects/${projectId}?tenant_id=${encodeURIComponent(this.tenantId)}`, { method: 'GET', headers: { 'Content-Type': 'application/json', 'X-Tenant-ID': this.tenantId, }, } ) return response } /** * Archive (soft-delete) a project */ async archiveProject(projectId: string): Promise { await this.fetchWithRetry<{ success: boolean }>( `${this.baseUrl}/projects/${projectId}?tenant_id=${encodeURIComponent(this.tenantId)}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json', 'X-Tenant-ID': this.tenantId, }, } ) } /** * Restore an archived project */ async restoreProject(projectId: string): Promise { const response = await this.fetchWithRetry( `${this.baseUrl}/projects/${projectId}/restore?tenant_id=${encodeURIComponent(this.tenantId)}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Tenant-ID': this.tenantId, }, } ) return response } /** * Permanently delete a project and all data */ async permanentlyDeleteProject(projectId: string): Promise { await this.fetchWithRetry<{ success: boolean }>( `${this.baseUrl}/projects/${projectId}/permanent?tenant_id=${encodeURIComponent(this.tenantId)}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json', 'X-Tenant-ID': this.tenantId, }, } ) } // =========================================================================== // WIKI (read-only knowledge base) // =========================================================================== /** * List all wiki categories with article counts */ async listWikiCategories(): Promise { const data = await this.fetchWithRetry<{ categories: Array<{ id: string; name: string; description: string; icon: string; sort_order: number; article_count: number }> }>( `${this.baseUrl}/wiki?endpoint=categories`, { method: 'GET' } ) return (data.categories || []).map(c => ({ id: c.id, name: c.name, description: c.description, icon: c.icon, sortOrder: c.sort_order, articleCount: c.article_count, })) } /** * List wiki articles, optionally filtered by category */ async listWikiArticles(categoryId?: string): Promise { const params = new URLSearchParams({ endpoint: 'articles' }) if (categoryId) params.set('category_id', categoryId) const data = await this.fetchWithRetry<{ articles: Array<{ id: string; category_id: string; category_name: string; title: string; summary: string; content: string; legal_refs: string[]; tags: string[]; relevance: string; source_urls: string[]; version: number; updated_at: string }> }>( `${this.baseUrl}/wiki?${params.toString()}`, { method: 'GET' } ) return (data.articles || []).map(a => ({ id: a.id, categoryId: a.category_id, categoryName: a.category_name, title: a.title, summary: a.summary, content: a.content, legalRefs: a.legal_refs || [], tags: a.tags || [], relevance: a.relevance as WikiArticle['relevance'], sourceUrls: a.source_urls || [], version: a.version, updatedAt: a.updated_at, })) } /** * Get a single wiki article by ID */ async getWikiArticle(id: string): Promise { const data = await this.fetchWithRetry<{ id: string; category_id: string; category_name: string; title: string; summary: string; content: string; legal_refs: string[]; tags: string[]; relevance: string; source_urls: string[]; version: number; updated_at: string }>( `${this.baseUrl}/wiki?endpoint=article&id=${encodeURIComponent(id)}`, { method: 'GET' } ) return { id: data.id, categoryId: data.category_id, categoryName: data.category_name, title: data.title, summary: data.summary, content: data.content, legalRefs: data.legal_refs || [], tags: data.tags || [], relevance: data.relevance as WikiArticle['relevance'], sourceUrls: data.source_urls || [], version: data.version, updatedAt: data.updated_at, } } /** * Full-text search across wiki articles */ async searchWiki(query: string): Promise { const data = await this.fetchWithRetry<{ results: Array<{ id: string; title: string; summary: string; category_name: string; relevance: string; highlight: string }> }>( `${this.baseUrl}/wiki?endpoint=search&q=${encodeURIComponent(query)}`, { method: 'GET' } ) return (data.results || []).map(r => ({ id: r.id, title: r.title, summary: r.summary, categoryName: r.category_name, relevance: r.relevance, highlight: r.highlight, })) } /** * Health check */ async healthCheck(): Promise { try { const response = await this.fetchWithTimeout( `${this.baseUrl}/health`, { method: 'GET' }, `health-${Date.now()}` ) return response.ok } catch { return false } } } // ============================================================================= // 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 }