Files
git.stella-ops.org/docs/modules/ui/api-strategy.md
master a4badc275e 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.
2025-12-29 19:12:38 +02:00

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);
});