StellaOps UI API Strategy

Currency note (2026-09-26, DOC-2). There is no core/api/generated/ directory: Console clients are hand-written per backend surface under src/Web/StellaOps.Web/src/app/core/api/, and every backend path prefix lives in the single registry core/api/api-path-prefixes.ts. Endpoint paths in the examples below are illustrative and were not line-verified; see architecture.md §6.

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/jobengine/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

Runtime API failures must stay visible to the owning view. Service wrappers may record store error state, but they must rethrow or return a typed unavailable result; they must not convert failed HTTP calls into null, [], or empty success unless the view also renders an explicit degraded/unavailable state.

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