/** * SDK API Client * * Centralized API client for SDK state management with error handling, * retry logic, and optimistic locking support. */ import { SDKState, CheckpointStatus } 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 timeout: number private abortControllers: Map = new Map() constructor(options: { baseUrl?: string tenantId: string timeout?: number }) { this.baseUrl = options.baseUrl || DEFAULT_BASE_URL this.tenantId = options.tenantId 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 response = await this.fetchWithRetry>( `${this.baseUrl}/state?tenantId=${encodeURIComponent(this.tenantId)}`, { 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, 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 { await this.fetchWithRetry>( `${this.baseUrl}/state?tenantId=${encodeURIComponent(this.tenantId)}`, { 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 } /** * 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): SDKApiClient { if (!clientInstance && !tenantId) { throw new Error('SDKApiClient not initialized. Provide tenantId on first call.') } if (!clientInstance && tenantId) { clientInstance = new SDKApiClient({ tenantId }) } if (tenantId && clientInstance && clientInstance.getTenantId() !== tenantId) { clientInstance.setTenantId(tenantId) } return clientInstance! } export function resetSDKApiClient(): void { if (clientInstance) { clientInstance.cancelAllRequests() } clientInstance = null }