# StellaOps UI API Strategy ## Overview This document describes the API client architecture, code generation strategy, and data fetching patterns used in the StellaOps web interface. ## API Client Architecture ### Client Organization ``` src/app/core/api/ ├── generated/ # Auto-generated from OpenAPI │ ├── scanner-client.ts │ ├── policy-client.ts │ ├── orchestrator-client.ts │ └── ... ├── services/ # Higher-level service wrappers │ ├── findings.service.ts │ ├── policy.service.ts │ └── ... ├── interceptors/ # HTTP interceptors │ ├── auth.interceptor.ts │ ├── error.interceptor.ts │ ├── cache.interceptor.ts │ └── retry.interceptor.ts └── api.module.ts ``` ### OpenAPI Client Generation ```bash # Generate clients from OpenAPI specs npm run api:generate # Script in package.json { "scripts": { "api:generate": "openapi-generator-cli generate -g typescript-angular -i ../api/openapi/*.yaml -o src/app/core/api/generated" } } ``` ### Generated Client Configuration ```typescript // api.module.ts @NgModule({ imports: [HttpClientModule], providers: [ { provide: BASE_PATH, useValue: environment.apiUrl }, { provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true }, { provide: HTTP_INTERCEPTORS, useClass: ErrorInterceptor, multi: true }, { provide: HTTP_INTERCEPTORS, useClass: CacheInterceptor, multi: true }, { provide: HTTP_INTERCEPTORS, useClass: RetryInterceptor, multi: true }, ], }) export class ApiModule {} ``` ## HTTP Interceptors ### Authentication Interceptor ```typescript // auth.interceptor.ts @Injectable() export class AuthInterceptor implements HttpInterceptor { constructor(private auth: AuthService) {} intercept(req: HttpRequest, next: HttpHandler): Observable> { const token = this.auth.getAccessToken(); if (token && this.shouldAddToken(req)) { req = req.clone({ setHeaders: { Authorization: `Bearer ${token}`, }, }); } return next.handle(req).pipe( catchError(error => { if (error.status === 401) { return this.handle401(req, next); } return throwError(() => error); }) ); } private handle401(req: HttpRequest, next: HttpHandler): Observable> { return this.auth.refreshToken().pipe( switchMap(newToken => { req = req.clone({ setHeaders: { Authorization: `Bearer ${newToken}` }, }); return next.handle(req); }), catchError(() => { this.auth.logout(); return throwError(() => new Error('Session expired')); }) ); } } ``` ### Error Interceptor ```typescript // error.interceptor.ts @Injectable() export class ErrorInterceptor implements HttpInterceptor { constructor( private errorHandler: ErrorHandlerService, private toast: ToastService ) {} intercept(req: HttpRequest, next: HttpHandler): Observable> { return next.handle(req).pipe( catchError((error: HttpErrorResponse) => { const apiError = this.parseError(error); // Handle different error types switch (apiError.type) { case 'validation': // Let component handle validation errors break; case 'auth': // Handled by auth interceptor break; case 'rate_limit': this.toast.warning(`Rate limited. Retry in ${apiError.retryAfter}s`); break; case 'service': this.toast.error('Service temporarily unavailable'); break; default: this.errorHandler.handle(apiError); } return throwError(() => apiError); }) ); } private parseError(error: HttpErrorResponse): ApiError { return { type: this.getErrorType(error.status), status: error.status, message: error.error?.message || error.message, code: error.error?.code, details: error.error?.details, retryAfter: error.headers.get('Retry-After'), requestId: error.headers.get('X-Request-ID'), }; } } ``` ### Cache Interceptor ```typescript // cache.interceptor.ts @Injectable() export class CacheInterceptor implements HttpInterceptor { constructor(private cache: CacheService) {} intercept(req: HttpRequest, next: HttpHandler): Observable> { // Only cache GET requests if (req.method !== 'GET') { return next.handle(req); } // Check for no-cache header if (req.headers.has('x-no-cache')) { return next.handle(req); } const cacheKey = this.getCacheKey(req); const cached = this.cache.get(cacheKey); // Stale-while-revalidate pattern if (cached) { // Return cached immediately const cached$ = of(new HttpResponse({ body: cached.data })); // Revalidate in background const fresh$ = this.revalidate(req, next, cacheKey, cached.etag); return merge(cached$, fresh$).pipe( filter(response => response instanceof HttpResponse) ); } return this.fetchAndCache(req, next, cacheKey); } private revalidate( req: HttpRequest, next: HttpHandler, cacheKey: string, etag: string ): Observable> { const conditionalReq = req.clone({ setHeaders: { 'If-None-Match': etag }, }); return next.handle(conditionalReq).pipe( tap(event => { if (event instanceof HttpResponse && event.status !== 304) { this.cache.set(cacheKey, { data: event.body, etag: event.headers.get('ETag'), timestamp: Date.now(), }); } }), filter(event => event instanceof HttpResponse && event.status !== 304), catchError(() => EMPTY) // Silently fail revalidation ); } } ``` ### Retry Interceptor ```typescript // retry.interceptor.ts @Injectable() export class RetryInterceptor implements HttpInterceptor { private retryConfig = { maxRetries: 3, retryDelay: 1000, retryableStatuses: [408, 500, 502, 503, 504], }; intercept(req: HttpRequest, next: HttpHandler): Observable> { return next.handle(req).pipe( retryWhen(errors => errors.pipe( mergeMap((error, index) => { if ( index < this.retryConfig.maxRetries && this.shouldRetry(error) ) { const delay = this.calculateDelay(error, index); return timer(delay); } return throwError(() => error); }) ) ) ); } private shouldRetry(error: HttpErrorResponse): boolean { return this.retryConfig.retryableStatuses.includes(error.status); } private calculateDelay(error: HttpErrorResponse, retryIndex: number): number { // Use Retry-After header if present const retryAfter = error.headers.get('Retry-After'); if (retryAfter) { return parseInt(retryAfter, 10) * 1000; } // Exponential backoff with jitter const baseDelay = this.retryConfig.retryDelay; const exponential = Math.pow(2, retryIndex) * baseDelay; const jitter = Math.random() * baseDelay; return exponential + jitter; } } ``` ## Data Fetching Patterns ### Service Layer Pattern ```typescript // findings.service.ts @Injectable({ providedIn: 'root' }) export class FindingsService { private readonly cache = new Map>(); constructor(private api: ScannerApiService) {} getFindings(params: FindingsQuery): Observable> { return this.api.listFindings(params).pipe( map(response => this.transformResponse(response)), shareReplay(1) ); } getFinding(id: string): Observable { const cacheKey = `finding:${id}`; if (!this.cache.has(cacheKey)) { this.cache.set(cacheKey, this.api.getFinding(id).pipe( map(response => this.transformFinding(response)), shareReplay({ bufferSize: 1, refCount: true }) )); } return this.cache.get(cacheKey)!; } invalidateCache(id?: string): void { if (id) { this.cache.delete(`finding:${id}`); } else { this.cache.clear(); } } } ``` ### Pagination with Virtual Scrolling ```typescript // infinite-scroll.service.ts @Injectable() export class InfiniteScrollService { private items$ = new BehaviorSubject([]); private loading$ = new BehaviorSubject(false); private cursor: string | null = null; private hasMore = true; readonly state$ = combineLatest([ this.items$, this.loading$, ]).pipe( map(([items, loading]) => ({ items, loading, hasMore: this.hasMore })) ); constructor(private fetchFn: (cursor: string | null) => Observable>) {} loadMore(): void { if (this.loading$.value || !this.hasMore) return; this.loading$.next(true); this.fetchFn(this.cursor).subscribe({ next: response => { this.items$.next([...this.items$.value, ...response.items]); this.cursor = response.nextCursor; this.hasMore = !!response.nextCursor; this.loading$.next(false); }, error: () => { this.loading$.next(false); }, }); } reset(): void { this.items$.next([]); this.cursor = null; this.hasMore = true; } } ``` ### Real-time Updates with SSE ```typescript // sse.service.ts @Injectable({ providedIn: 'root' }) export class SseService { connect(url: string): Observable { return new Observable(observer => { const eventSource = new EventSource(url); eventSource.onmessage = event => { try { observer.next(JSON.parse(event.data)); } catch (e) { observer.error(new Error('Failed to parse SSE data')); } }; eventSource.onerror = () => { observer.error(new Error('SSE connection failed')); eventSource.close(); }; return () => eventSource.close(); }).pipe( retryWhen(errors => errors.pipe(delay(5000))) ); } } // Usage in component @Component({...}) export class JobDetailComponent { jobUpdates$ = this.sse.connect(`/api/v1/orchestrator/jobs/${this.jobId}/stream`); } ``` ### Optimistic Updates ```typescript // optimistic-update.service.ts @Injectable() export class OptimisticUpdateService { private optimisticItems = new Map(); constructor( private store: Store, private api: ApiService ) {} async update(id: string, changes: Partial): Promise { const original = this.store.get(id); if (!original) return; // Apply optimistic update const optimistic = { ...original, ...changes }; this.optimisticItems.set(id, original); this.store.update(id, optimistic); try { // Persist to server const result = await firstValueFrom(this.api.update(id, changes)); this.store.update(id, result); this.optimisticItems.delete(id); } catch (error) { // Rollback on failure this.store.update(id, original); this.optimisticItems.delete(id); throw error; } } } ``` ## Error Handling ### API Error Types ```typescript // api-error.ts interface ApiError { type: 'validation' | 'auth' | 'rate_limit' | 'service' | 'network' | 'unknown'; status: number; message: string; code?: string; details?: Record; retryAfter?: string; requestId?: string; } // Error type mapping function getErrorType(status: number): ApiError['type'] { if (status === 0) return 'network'; if (status === 400) return 'validation'; if (status === 401 || status === 403) return 'auth'; if (status === 429) return 'rate_limit'; if (status >= 500) return 'service'; return 'unknown'; } ``` ### Component Error Handling ```typescript // findings.component.ts @Component({...}) export class FindingsComponent { findings$: Observable; error$ = new Subject(); ngOnInit(): void { this.findings$ = this.findingsService.getFindings(this.params).pipe( catchError(error => { this.error$.next(error); return of([]); }) ); } } ``` ## Testing ### API Mock Service ```typescript // api-mock.service.ts @Injectable() export class ApiMockService { private mocks = new Map(); mock(endpoint: string, response: any): void { this.mocks.set(endpoint, response); } intercept(req: HttpRequest, next: HttpHandler): Observable> { const mock = this.mocks.get(req.url); if (mock) { return of(new HttpResponse({ body: mock })); } return next.handle(req); } } // Usage in tests beforeEach(() => { TestBed.configureTestingModule({ providers: [ { provide: HTTP_INTERCEPTORS, useClass: ApiMockService, multi: true }, ], }); const mockService = TestBed.inject(ApiMockService); mockService.mock('/api/v1/findings', mockFindings); }); ``` ## Related Documentation - [UI Architecture](./architecture.md) - [Offline Implementation](./offline-implementation.md) - [SPRINT_039 - Error Boundary Patterns](../../implplan/SPRINT_20251229_039_FE_error_boundary_patterns.md)