Files
Goa-gel-fullstack/frontend/src/app/core/services/api.service.ts

408 lines
11 KiB
TypeScript
Raw Normal View History

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 { environment } from '../../../environments/environment';
// 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<T> {
progress: number;
loaded: number;
total: number;
complete: boolean;
response?: T;
}
export interface ApiResponse<T> {
success: boolean;
data: T;
timestamp: string;
message?: string;
}
export interface PaginatedResponse<T> {
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<T>(response: ApiResponse<T> | 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;
}
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 baseUrl = environment.apiBaseUrl;
/**
* Cache for GET requests that should be shared
*/
private readonly cache = new Map<string, Observable<unknown>>();
get<T>(
path: string,
params?: Record<string, string | number | boolean | undefined | null>,
options?: RequestOptions
): Observable<T> {
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<ApiResponse<T>>(`${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<T>(response)),
catchError((error) => this.handleError(error, path))
);
}
/**
* GET with caching support using shareReplay
* Useful for frequently accessed, rarely changing data
*/
getCached<T>(
path: string,
params?: Record<string, string | number | boolean>,
cacheTimeMs = 60000
): Observable<T> {
const cacheKey = `${path}?${JSON.stringify(params || {})}`;
if (!this.cache.has(cacheKey)) {
const request$ = this.get<T>(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<T>;
}
/**
* 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<T>(
path: string,
params?: Record<string, string | number | boolean>,
options?: RequestOptions
): Observable<T> {
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<T>(`${this.baseUrl}${path}`, { params: httpParams }).pipe(
timeout(timeoutMs),
catchError((error) => this.handleError(error, path))
);
}
post<T>(path: string, body: unknown, options?: RequestOptions): Observable<T> {
const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
return this.http.post<ApiResponse<T>>(`${this.baseUrl}${path}`, body ?? {}).pipe(
timeout(timeoutMs),
map((response) => extractData<T>(response)),
catchError((error) => this.handleError(error, path))
);
}
postRaw<T>(path: string, body: unknown, options?: RequestOptions): Observable<T> {
const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
return this.http.post<T>(`${this.baseUrl}${path}`, body ?? {}).pipe(
timeout(timeoutMs),
catchError((error) => this.handleError(error, path))
);
}
put<T>(path: string, body: unknown, options?: RequestOptions): Observable<T> {
const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
return this.http.put<ApiResponse<T>>(`${this.baseUrl}${path}`, body ?? {}).pipe(
timeout(timeoutMs),
map((response) => extractData<T>(response)),
catchError((error) => this.handleError(error, path))
);
}
patch<T>(path: string, body: unknown, options?: RequestOptions): Observable<T> {
const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
return this.http.patch<ApiResponse<T>>(`${this.baseUrl}${path}`, body ?? {}).pipe(
timeout(timeoutMs),
map((response) => extractData<T>(response)),
catchError((error) => this.handleError(error, path))
);
}
delete<T>(path: string, options?: RequestOptions): Observable<T> {
const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
return this.http.delete<ApiResponse<T>>(`${this.baseUrl}${path}`).pipe(
timeout(timeoutMs),
map((response) => extractData<T>(response)),
catchError((error) => this.handleError(error, path))
);
}
upload<T>(path: string, formData: FormData, options?: RequestOptions): Observable<T> {
const timeoutMs = options?.timeoutMs ?? UPLOAD_TIMEOUT_MS;
return this.http.post<ApiResponse<T>>(`${this.baseUrl}${path}`, formData).pipe(
timeout(timeoutMs),
map((response) => extractData<T>(response)),
catchError((error) => this.handleError(error, path))
);
}
/**
* Upload with progress tracking
* Returns an observable that emits upload progress and final response
*/
uploadWithProgress<T>(
path: string,
formData: FormData,
options?: RequestOptions
): Observable<UploadProgress<T>> {
const timeoutMs = options?.timeoutMs ?? UPLOAD_TIMEOUT_MS;
const req = new HttpRequest('POST', `${this.baseUrl}${path}`, formData, {
reportProgress: true,
});
return this.http.request<ApiResponse<T>>(req).pipe(
timeout(timeoutMs),
map((event: HttpEvent<ApiResponse<T>>) => {
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<T>;
}
case HttpEventType.Response: {
const responseData = event.body?.data;
return {
progress: 100,
loaded: 1,
total: 1,
complete: true,
response: responseData,
} as UploadProgress<T>;
}
default:
return {
progress: 0,
loaded: 0,
total: 0,
complete: false,
} as UploadProgress<T>;
}
}),
catchError((error) => this.handleError(error, path))
);
}
download(path: string, options?: RequestOptions): Observable<Blob> {
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<Blob> {
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<never> {
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 };