2026-02-07 10:23:29 -04:00
|
|
|
import { Injectable, inject } from '@angular/core';
|
2026-02-08 02:10:09 -04:00
|
|
|
import {
|
|
|
|
|
HttpClient,
|
|
|
|
|
HttpParams,
|
|
|
|
|
HttpHeaders,
|
|
|
|
|
HttpEvent,
|
|
|
|
|
HttpEventType,
|
|
|
|
|
HttpRequest,
|
|
|
|
|
HttpErrorResponse,
|
|
|
|
|
} from '@angular/common/http';
|
|
|
|
|
import {
|
|
|
|
|
Observable,
|
|
|
|
|
map,
|
|
|
|
|
filter,
|
|
|
|
|
timeout,
|
|
|
|
|
retry,
|
|
|
|
|
catchError,
|
|
|
|
|
throwError,
|
|
|
|
|
shareReplay,
|
|
|
|
|
of,
|
|
|
|
|
} from 'rxjs';
|
2026-02-07 10:23:29 -04:00
|
|
|
import { environment } from '../../../environments/environment';
|
|
|
|
|
|
2026-02-08 02:10:09 -04:00
|
|
|
// 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;
|
|
|
|
|
|
2026-02-07 10:23:29 -04:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-08 02:10:09 -04:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-07 10:23:29 -04:00
|
|
|
@Injectable({
|
|
|
|
|
providedIn: 'root',
|
|
|
|
|
})
|
|
|
|
|
export class ApiService {
|
|
|
|
|
private readonly http = inject(HttpClient);
|
|
|
|
|
private readonly baseUrl = environment.apiBaseUrl;
|
|
|
|
|
|
2026-02-08 02:10:09 -04:00
|
|
|
/**
|
|
|
|
|
* 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> {
|
2026-02-07 10:23:29 -04:00
|
|
|
let httpParams = new HttpParams();
|
2026-02-08 02:10:09 -04:00
|
|
|
|
2026-02-07 10:23:29 -04:00
|
|
|
if (params) {
|
|
|
|
|
Object.entries(params).forEach(([key, value]) => {
|
2026-02-08 02:10:09 -04:00
|
|
|
if (value !== undefined && value !== null && value !== '') {
|
2026-02-07 10:23:29 -04:00
|
|
|
httpParams = httpParams.set(key, String(value));
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-02-08 02:10:09 -04:00
|
|
|
|
|
|
|
|
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>;
|
2026-02-07 10:23:29 -04:00
|
|
|
}
|
|
|
|
|
|
2026-02-08 02:10:09 -04:00
|
|
|
/**
|
|
|
|
|
* 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> {
|
2026-02-07 10:23:29 -04:00
|
|
|
let httpParams = new HttpParams();
|
2026-02-08 02:10:09 -04:00
|
|
|
|
2026-02-07 10:23:29 -04:00
|
|
|
if (params) {
|
|
|
|
|
Object.entries(params).forEach(([key, value]) => {
|
|
|
|
|
if (value !== undefined && value !== null) {
|
|
|
|
|
httpParams = httpParams.set(key, String(value));
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-02-08 02:10:09 -04:00
|
|
|
|
|
|
|
|
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))
|
|
|
|
|
);
|
2026-02-07 10:23:29 -04:00
|
|
|
}
|
|
|
|
|
|
2026-02-08 02:10:09 -04:00
|
|
|
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))
|
|
|
|
|
);
|
2026-02-07 10:23:29 -04:00
|
|
|
}
|
|
|
|
|
|
2026-02-08 02:10:09 -04:00
|
|
|
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))
|
|
|
|
|
);
|
2026-02-07 10:23:29 -04:00
|
|
|
}
|
|
|
|
|
|
2026-02-08 02:10:09 -04:00
|
|
|
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))
|
|
|
|
|
);
|
2026-02-07 10:23:29 -04:00
|
|
|
}
|
|
|
|
|
|
2026-02-08 02:10:09 -04:00
|
|
|
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))
|
|
|
|
|
);
|
2026-02-07 10:23:29 -04:00
|
|
|
}
|
|
|
|
|
|
2026-02-08 02:10:09 -04:00
|
|
|
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))
|
|
|
|
|
);
|
2026-02-07 10:23:29 -04:00
|
|
|
}
|
|
|
|
|
|
2026-02-08 02:10:09 -04:00
|
|
|
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))
|
|
|
|
|
);
|
2026-02-07 10:23:29 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Upload with progress tracking
|
|
|
|
|
* Returns an observable that emits upload progress and final response
|
|
|
|
|
*/
|
2026-02-08 02:10:09 -04:00
|
|
|
uploadWithProgress<T>(
|
|
|
|
|
path: string,
|
|
|
|
|
formData: FormData,
|
|
|
|
|
options?: RequestOptions
|
|
|
|
|
): Observable<UploadProgress<T>> {
|
|
|
|
|
const timeoutMs = options?.timeoutMs ?? UPLOAD_TIMEOUT_MS;
|
|
|
|
|
|
2026-02-07 10:23:29 -04:00
|
|
|
const req = new HttpRequest('POST', `${this.baseUrl}${path}`, formData, {
|
|
|
|
|
reportProgress: true,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return this.http.request<ApiResponse<T>>(req).pipe(
|
2026-02-08 02:10:09 -04:00
|
|
|
timeout(timeoutMs),
|
2026-02-07 10:23:29 -04:00
|
|
|
map((event: HttpEvent<ApiResponse<T>>) => {
|
|
|
|
|
switch (event.type) {
|
2026-02-08 02:10:09 -04:00
|
|
|
case HttpEventType.UploadProgress: {
|
|
|
|
|
const total = event.total ?? 0;
|
|
|
|
|
const loaded = event.loaded ?? 0;
|
2026-02-07 10:23:29 -04:00
|
|
|
const progress = total > 0 ? Math.round((loaded / total) * 100) : 0;
|
|
|
|
|
return {
|
|
|
|
|
progress,
|
|
|
|
|
loaded,
|
|
|
|
|
total,
|
|
|
|
|
complete: false,
|
|
|
|
|
} as UploadProgress<T>;
|
2026-02-08 02:10:09 -04:00
|
|
|
}
|
2026-02-07 10:23:29 -04:00
|
|
|
|
2026-02-08 02:10:09 -04:00
|
|
|
case HttpEventType.Response: {
|
|
|
|
|
const responseData = event.body?.data;
|
2026-02-07 10:23:29 -04:00
|
|
|
return {
|
|
|
|
|
progress: 100,
|
2026-02-08 02:10:09 -04:00
|
|
|
loaded: 1,
|
2026-02-07 10:23:29 -04:00
|
|
|
total: 1,
|
|
|
|
|
complete: true,
|
2026-02-08 02:10:09 -04:00
|
|
|
response: responseData,
|
2026-02-07 10:23:29 -04:00
|
|
|
} as UploadProgress<T>;
|
2026-02-08 02:10:09 -04:00
|
|
|
}
|
2026-02-07 10:23:29 -04:00
|
|
|
|
|
|
|
|
default:
|
|
|
|
|
return {
|
|
|
|
|
progress: 0,
|
|
|
|
|
loaded: 0,
|
|
|
|
|
total: 0,
|
|
|
|
|
complete: false,
|
|
|
|
|
} as UploadProgress<T>;
|
|
|
|
|
}
|
2026-02-08 02:10:09 -04:00
|
|
|
}),
|
|
|
|
|
catchError((error) => this.handleError(error, path))
|
2026-02-07 10:23:29 -04:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-08 02:10:09 -04:00
|
|
|
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))
|
|
|
|
|
);
|
2026-02-07 10:23:29 -04:00
|
|
|
}
|
|
|
|
|
|
2026-02-08 02:10:09 -04:00
|
|
|
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));
|
2026-02-07 10:23:29 -04:00
|
|
|
}
|
|
|
|
|
}
|
2026-02-08 02:10:09 -04:00
|
|
|
|
|
|
|
|
// Export utility functions for use in other services
|
|
|
|
|
export { validateId, validatePagination };
|