UI work to fill SBOM sourcing management gap. UI planning remaining functionality exposure. Work on CI/Tests stabilization
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.
This commit is contained in:
529
docs/modules/ui/api-strategy.md
Normal file
529
docs/modules/ui/api-strategy.md
Normal file
@@ -0,0 +1,529 @@
|
||||
# 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)
|
||||
Reference in New Issue
Block a user