import { Injectable, inject } from '@angular/core'; import { HttpClient, HttpParams, HttpHeaders, HttpEvent, HttpEventType, HttpRequest, HttpErrorResponse, } from '@angular/common/http'; import { Observable, map, filter, timeout, retry, catchError, throwError, shareReplay, of, } from 'rxjs'; import { RuntimeConfigService } from './runtime-config.service'; // Configuration constants const DEFAULT_TIMEOUT_MS = 30000; // 30 seconds const UPLOAD_TIMEOUT_MS = 300000; // 5 minutes for uploads const MAX_RETRIES = 2; const RETRY_DELAY_MS = 1000; export interface UploadProgress { progress: number; loaded: number; total: number; complete: boolean; response?: T; } export interface ApiResponse { success: boolean; data: T; timestamp: string; message?: string; } export interface PaginatedResponse { data: T[]; total: number; page: number; limit: number; totalPages: number; hasNextPage: boolean; } export interface RequestOptions { timeoutMs?: number; retries?: number; skipRetry?: boolean; } /** * Validates and sanitizes an ID parameter * @throws Error if ID is invalid */ function validateId(id: string | undefined | null, fieldName = 'ID'): string { if (id === undefined || id === null) { throw new Error(`${fieldName} is required`); } if (typeof id !== 'string') { throw new Error(`${fieldName} must be a string`); } const trimmedId = id.trim(); if (trimmedId.length === 0) { throw new Error(`${fieldName} cannot be empty`); } // Check for dangerous characters that could lead to path traversal or injection if (/[\/\\<>|"'`;&$]/.test(trimmedId)) { throw new Error(`${fieldName} contains invalid characters`); } return trimmedId; } /** * Validates pagination parameters */ function validatePagination(page?: number, limit?: number): { page: number; limit: number } { const validPage = typeof page === 'number' && !isNaN(page) ? Math.max(1, Math.floor(page)) : 1; const validLimit = typeof limit === 'number' && !isNaN(limit) ? Math.min(100, Math.max(1, Math.floor(limit))) : 10; return { page: validPage, limit: validLimit }; } /** * Safely extracts data from API response with null checks */ function extractData(response: ApiResponse | null | undefined): T { if (!response) { throw new Error('Empty response received from server'); } // Handle case where response itself is the data (no wrapper) if (!('data' in response) && !('success' in response)) { return response as unknown as T; } // Handle paginated responses: have 'data' and pagination fields but no 'success' // These should be returned as-is, not unwrapped if ('data' in response && !('success' in response) && ('total' in response || 'page' in response)) { return response as unknown as T; } if (response.data === undefined) { // Return null as T if data is explicitly undefined but response exists return null as T; } return response.data; } /** * Determines if an error is retryable */ function isRetryableError(error: HttpErrorResponse): boolean { // Network errors (status 0) or server errors (5xx) are retryable // 429 (rate limiting) is also retryable return error.status === 0 || error.status === 429 || (error.status >= 500 && error.status < 600); } @Injectable({ providedIn: 'root', }) export class ApiService { private readonly http = inject(HttpClient); private readonly configService = inject(RuntimeConfigService); /** * Get API base URL from runtime config (supports deployment-time configuration) */ private get baseUrl(): string { return this.configService.apiBaseUrl; } /** * Cache for GET requests that should be shared */ private readonly cache = new Map>(); get( path: string, params?: Record, options?: RequestOptions ): Observable { let httpParams = new HttpParams(); if (params) { Object.entries(params).forEach(([key, value]) => { if (value !== undefined && value !== null && value !== '') { httpParams = httpParams.set(key, String(value)); } }); } const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS; const retries = options?.skipRetry ? 0 : (options?.retries ?? MAX_RETRIES); return this.http.get>(`${this.baseUrl}${path}`, { params: httpParams }).pipe( timeout(timeoutMs), retry({ count: retries, delay: (error, retryCount) => { if (isRetryableError(error)) { return of(null).pipe( // Exponential backoff timeout(RETRY_DELAY_MS * Math.pow(2, retryCount - 1)) ); } return throwError(() => error); }, }), map((response) => extractData(response)), catchError((error) => this.handleError(error, path)) ); } /** * GET with caching support using shareReplay * Useful for frequently accessed, rarely changing data */ getCached( path: string, params?: Record, cacheTimeMs = 60000 ): Observable { const cacheKey = `${path}?${JSON.stringify(params || {})}`; if (!this.cache.has(cacheKey)) { const request$ = this.get(path, params).pipe( shareReplay({ bufferSize: 1, refCount: true }) ); this.cache.set(cacheKey, request$); // Clear cache after specified time setTimeout(() => this.cache.delete(cacheKey), cacheTimeMs); } return this.cache.get(cacheKey) as Observable; } /** * Clear the cache for a specific path or all cache */ clearCache(path?: string): void { if (path) { // Clear all cache entries that start with this path for (const key of this.cache.keys()) { if (key.startsWith(path)) { this.cache.delete(key); } } } else { this.cache.clear(); } } getRaw( path: string, params?: Record, options?: RequestOptions ): Observable { let httpParams = new HttpParams(); if (params) { Object.entries(params).forEach(([key, value]) => { if (value !== undefined && value !== null) { httpParams = httpParams.set(key, String(value)); } }); } const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS; return this.http.get(`${this.baseUrl}${path}`, { params: httpParams }).pipe( timeout(timeoutMs), catchError((error) => this.handleError(error, path)) ); } post(path: string, body: unknown, options?: RequestOptions): Observable { const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS; return this.http.post>(`${this.baseUrl}${path}`, body ?? {}).pipe( timeout(timeoutMs), map((response) => extractData(response)), catchError((error) => this.handleError(error, path)) ); } postRaw(path: string, body: unknown, options?: RequestOptions): Observable { const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS; return this.http.post(`${this.baseUrl}${path}`, body ?? {}).pipe( timeout(timeoutMs), catchError((error) => this.handleError(error, path)) ); } put(path: string, body: unknown, options?: RequestOptions): Observable { const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS; return this.http.put>(`${this.baseUrl}${path}`, body ?? {}).pipe( timeout(timeoutMs), map((response) => extractData(response)), catchError((error) => this.handleError(error, path)) ); } patch(path: string, body: unknown, options?: RequestOptions): Observable { const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS; return this.http.patch>(`${this.baseUrl}${path}`, body ?? {}).pipe( timeout(timeoutMs), map((response) => extractData(response)), catchError((error) => this.handleError(error, path)) ); } delete(path: string, options?: RequestOptions): Observable { const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS; return this.http.delete>(`${this.baseUrl}${path}`).pipe( timeout(timeoutMs), map((response) => extractData(response)), catchError((error) => this.handleError(error, path)) ); } upload(path: string, formData: FormData, options?: RequestOptions): Observable { const timeoutMs = options?.timeoutMs ?? UPLOAD_TIMEOUT_MS; return this.http.post>(`${this.baseUrl}${path}`, formData).pipe( timeout(timeoutMs), map((response) => extractData(response)), catchError((error) => this.handleError(error, path)) ); } /** * Upload with progress tracking * Returns an observable that emits upload progress and final response */ uploadWithProgress( path: string, formData: FormData, options?: RequestOptions ): Observable> { const timeoutMs = options?.timeoutMs ?? UPLOAD_TIMEOUT_MS; const req = new HttpRequest('POST', `${this.baseUrl}${path}`, formData, { reportProgress: true, }); return this.http.request>(req).pipe( timeout(timeoutMs), map((event: HttpEvent>) => { switch (event.type) { case HttpEventType.UploadProgress: { const total = event.total ?? 0; const loaded = event.loaded ?? 0; const progress = total > 0 ? Math.round((loaded / total) * 100) : 0; return { progress, loaded, total, complete: false, } as UploadProgress; } case HttpEventType.Response: { // Handle both wrapped ({data: ...}) and unwrapped responses const body = event.body; let responseData: T | undefined; if (body && typeof body === 'object' && 'data' in body) { responseData = (body as ApiResponse).data; } else { responseData = body as T | undefined; } return { progress: 100, loaded: 1, total: 1, complete: true, response: responseData, } as UploadProgress; } default: return { progress: 0, loaded: 0, total: 0, complete: false, } as UploadProgress; } }), catchError((error) => this.handleError(error, path)) ); } download(path: string, options?: RequestOptions): Observable { const timeoutMs = options?.timeoutMs ?? UPLOAD_TIMEOUT_MS; // Downloads can be large return this.http .get(`${this.baseUrl}${path}`, { responseType: 'blob', }) .pipe( timeout(timeoutMs), catchError((error) => this.handleError(error, path)) ); } getBlob(url: string, options?: RequestOptions): Observable { const timeoutMs = options?.timeoutMs ?? UPLOAD_TIMEOUT_MS; return this.http.get(url, { responseType: 'blob' }).pipe( timeout(timeoutMs), catchError((error) => this.handleError(error, url)) ); } /** * Centralized error handling */ private handleError(error: unknown, context: string): Observable { let message = 'An unexpected error occurred'; if (error instanceof HttpErrorResponse) { if (error.status === 0) { message = 'Network error. Please check your connection.'; } else if (error.error?.message) { message = error.error.message; } else { message = `Request failed: ${error.statusText || error.status}`; } } else if (error instanceof Error) { if (error.name === 'TimeoutError') { message = 'Request timed out. Please try again.'; } else { message = error.message; } } console.error(`API Error [${context}]:`, error); return throwError(() => new Error(message)); } } // Export utility functions for use in other services export { validateId, validatePagination };