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.
530 lines
13 KiB
Markdown
530 lines
13 KiB
Markdown
# 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<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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```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<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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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);
|
|
});
|
|
```
|
|
|
|
## 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)
|