Introduces CGS determinism test runs to CI workflows for Windows, macOS, Linux, Alpine, and Debian, fulfilling CGS-008 cross-platform requirements. Updates local-ci scripts to support new smoke steps, test timeouts, progress intervals, and project slicing for improved test isolation and diagnostics.
13 KiB
13 KiB
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
# 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
// 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
// auth.interceptor.ts
@Injectable()
export class AuthInterceptor implements HttpInterceptor {
constructor(private auth: AuthService) {}
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
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<any>, next: HttpHandler): Observable<HttpEvent<any>> {
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
// error.interceptor.ts
@Injectable()
export class ErrorInterceptor implements HttpInterceptor {
constructor(
private errorHandler: ErrorHandlerService,
private toast: ToastService
) {}
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
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
// cache.interceptor.ts
@Injectable()
export class CacheInterceptor implements HttpInterceptor {
constructor(private cache: CacheService) {}
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
// 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<any>,
next: HttpHandler,
cacheKey: string,
etag: string
): Observable<HttpEvent<any>> {
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
// retry.interceptor.ts
@Injectable()
export class RetryInterceptor implements HttpInterceptor {
private retryConfig = {
maxRetries: 3,
retryDelay: 1000,
retryableStatuses: [408, 500, 502, 503, 504],
};
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
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
// findings.service.ts
@Injectable({ providedIn: 'root' })
export class FindingsService {
private readonly cache = new Map<string, Observable<Finding[]>>();
constructor(private api: ScannerApiService) {}
getFindings(params: FindingsQuery): Observable<PaginatedResponse<Finding>> {
return this.api.listFindings(params).pipe(
map(response => this.transformResponse(response)),
shareReplay(1)
);
}
getFinding(id: string): Observable<Finding> {
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
// infinite-scroll.service.ts
@Injectable()
export class InfiniteScrollService<T> {
private items$ = new BehaviorSubject<T[]>([]);
private loading$ = new BehaviorSubject<boolean>(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<PaginatedResponse<T>>) {}
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
// sse.service.ts
@Injectable({ providedIn: 'root' })
export class SseService {
connect<T>(url: string): Observable<T> {
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<JobUpdate>(`/api/v1/orchestrator/jobs/${this.jobId}/stream`);
}
Optimistic Updates
// optimistic-update.service.ts
@Injectable()
export class OptimisticUpdateService<T extends { id: string }> {
private optimisticItems = new Map<string, T>();
constructor(
private store: Store<T>,
private api: ApiService<T>
) {}
async update(id: string, changes: Partial<T>): Promise<void> {
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
// api-error.ts
interface ApiError {
type: 'validation' | 'auth' | 'rate_limit' | 'service' | 'network' | 'unknown';
status: number;
message: string;
code?: string;
details?: Record<string, string[]>;
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
// findings.component.ts
@Component({...})
export class FindingsComponent {
findings$: Observable<Finding[]>;
error$ = new Subject<ApiError>();
ngOnInit(): void {
this.findings$ = this.findingsService.getFindings(this.params).pipe(
catchError(error => {
this.error$.next(error);
return of([]);
})
);
}
}
Testing
API Mock Service
// api-mock.service.ts
@Injectable()
export class ApiMockService {
private mocks = new Map<string, any>();
mock(endpoint: string, response: any): void {
this.mocks.set(endpoint, response);
}
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
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);
});