Files
breakpilot-compliance/admin-compliance/lib/sdk/api-client.ts
Benjamin Admin 0affa4eb66
Some checks failed
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Failing after 33s
CI / test-python-backend-compliance (push) Successful in 34s
CI / test-python-document-crawler (push) Successful in 23s
CI / test-python-dsms-gateway (push) Successful in 19s
feat(sdk): Multi-Projekt-Architektur — mehrere Projekte pro Tenant
Jeder Tenant kann jetzt mehrere Compliance-Projekte anlegen (z.B. verschiedene
Produkte, Tochterunternehmen). CompanyProfile ist pro Projekt kopierbar und
danach unabhaengig editierbar. Multi-Tab-Support via separater BroadcastChannel
und localStorage Keys pro Projekt.

- Migration 039: compliance_projects Tabelle, sdk_states.project_id
- Backend: FastAPI CRUD-Routes fuer Projekte mit Tenant-Isolation
- Frontend: ProjectSelector UI, SDKProvider mit projectId, URL ?project=
- State API: UPSERT auf (tenant_id, project_id) mit Abwaertskompatibilitaet
- Tests: pytest fuer Model-Validierung, Row-Konvertierung, Tenant-Isolation
- Docs: MKDocs Seite, CLAUDE.md, Backend README

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 14:53:50 +01:00

732 lines
20 KiB
TypeScript

/**
* SDK API Client
*
* Centralized API client for SDK state management with error handling,
* retry logic, and optimistic locking support.
*/
import { SDKState, CheckpointStatus, ProjectInfo } from './types'
// =============================================================================
// TYPES
// =============================================================================
export interface APIResponse<T> {
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<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 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<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)
}
}
private 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))
}
// ---------------------------------------------------------------------------
// Public Methods - State Management
// ---------------------------------------------------------------------------
/**
* Load SDK state for the current tenant
*/
async getState(): Promise<StateResponse | null> {
try {
const params = new URLSearchParams({ tenantId: this.tenantId })
if (this.projectId) params.set('projectId', this.projectId)
const response = await this.fetchWithRetry<APIResponse<StateResponse>>(
`${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<StateResponse> {
const response = await this.fetchWithRetry<APIResponse<StateResponse>>(
`${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<void> {
const params = new URLSearchParams({ tenantId: this.tenantId })
if (this.projectId) params.set('projectId', this.projectId)
await this.fetchWithRetry<APIResponse<void>>(
`${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<CheckpointValidationResult> {
const response = await this.fetchWithRetry<APIResponse<CheckpointValidationResult>>(
`${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<Record<string, CheckpointStatus>> {
const response = await this.fetchWithRetry<APIResponse<Record<string, CheckpointStatus>>>(
`${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<APIResponse<{
currentStep: string
currentPhase: 1 | 2
completedSteps: string[]
suggestions: Array<{ stepId: string; reason: string }>
}>>(
`${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<APIResponse<{
stepId: string
phase: 1 | 2
}>>(
`${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<unknown> {
const response = await this.fetchWithRetry<APIResponse<unknown>>(
`${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<unknown[]> {
const response = await this.fetchWithRetry<APIResponse<unknown[]>>(
`${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<unknown> {
const response = await this.fetchWithRetry<APIResponse<unknown>>(
`${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<void> {
await this.fetchWithRetry<APIResponse<void>>(
`${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<unknown> {
const response = await this.fetchWithRetry<APIResponse<unknown>>(
`${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<unknown> {
const response = await this.fetchWithRetry<APIResponse<unknown>>(
`${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<Blob> {
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(): Promise<{ projects: ProjectInfo[]; total: number }> {
const response = await this.fetchWithRetry<{ projects: ProjectInfo[]; total: number }>(
`${this.baseUrl}/projects?tenant_id=${encodeURIComponent(this.tenantId)}`,
{
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<ProjectInfo> {
const response = await this.fetchWithRetry<ProjectInfo>(
`${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<ProjectInfo> {
const response = await this.fetchWithRetry<ProjectInfo>(
`${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
}
/**
* Archive (soft-delete) a project
*/
async archiveProject(projectId: string): Promise<void> {
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,
},
}
)
}
/**
* Health check
*/
async healthCheck(): Promise<boolean> {
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
}