import { Injectable, Inject } from "@nestjs/common"; import { Logger } from "nestjs-pino"; import { getErrorMessage } from "@bff/core/utils/error.util"; import type { WhmcsApiResponse, WhmcsErrorResponse } from "../../types/whmcs-api.types"; import type { WhmcsApiConfig, WhmcsRequestOptions, WhmcsConnectionStats, } from "../types/connection.types"; /** * Service for handling HTTP requests to WHMCS API * Manages timeouts, retries, and response parsing */ @Injectable() export class WhmcsHttpClientService { private stats: WhmcsConnectionStats = { totalRequests: 0, successfulRequests: 0, failedRequests: 0, averageResponseTime: 0, }; constructor(@Inject(Logger) private readonly logger: Logger) {} /** * Make HTTP request to WHMCS API */ async makeRequest( config: WhmcsApiConfig, action: string, params: Record, options: WhmcsRequestOptions = {} ): Promise> { const startTime = Date.now(); this.stats.totalRequests++; this.stats.lastRequestTime = new Date(); try { const response = await this.executeRequest(config, action, params, options); const responseTime = Date.now() - startTime; this.updateSuccessStats(responseTime); return response; } catch (error) { this.stats.failedRequests++; this.stats.lastErrorTime = new Date(); this.logger.error(`WHMCS HTTP request failed [${action}]`, { error: getErrorMessage(error), action, params: this.sanitizeLogParams(params), responseTime: Date.now() - startTime, }); throw error; } } /** * Get connection statistics */ getStats(): WhmcsConnectionStats { return { ...this.stats }; } /** * Reset connection statistics */ resetStats(): void { this.stats = { totalRequests: 0, successfulRequests: 0, failedRequests: 0, averageResponseTime: 0, }; } /** * Execute the actual HTTP request with retry logic */ private async executeRequest( config: WhmcsApiConfig, action: string, params: Record, options: WhmcsRequestOptions ): Promise> { const maxAttempts = options.retryAttempts ?? config.retryAttempts ?? 3; let lastError: Error; for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { return await this.performSingleRequest(config, action, params, options); } catch (error) { lastError = error as Error; if (attempt === maxAttempts) { break; } // Don't retry on certain error types if (this.shouldNotRetry(error)) { break; } const delay = this.calculateRetryDelay(attempt, config.retryDelay ?? 1000); this.logger.warn(`WHMCS request failed, retrying in ${delay}ms`, { action, attempt, maxAttempts, error: getErrorMessage(error), }); await this.sleep(delay); } } throw lastError!; } /** * Perform a single HTTP request */ private async performSingleRequest( config: WhmcsApiConfig, action: string, params: Record, options: WhmcsRequestOptions ): Promise> { const timeout = options.timeout ?? config.timeout ?? 30000; const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeout); try { const requestBody = this.buildRequestBody(config, action, params, options); const url = `${config.baseUrl}/includes/api.php`; const response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", "User-Agent": "CustomerPortal/1.0", }, body: requestBody, signal: controller.signal, }); clearTimeout(timeoutId); const responseText = await response.text(); if (!response.ok) { const snippet = responseText?.slice(0, 300); throw new Error( `HTTP ${response.status}: ${response.statusText}${snippet ? ` | Body: ${snippet}` : ""}` ); } return this.parseResponse(responseText, action, params); } finally { clearTimeout(timeoutId); } } /** * Build request body for WHMCS API */ private buildRequestBody( config: WhmcsApiConfig, action: string, params: Record, options: WhmcsRequestOptions ): string { const formData = new URLSearchParams(); // Add authentication if (options.useAdminAuth && config.adminUsername && config.adminPasswordHash) { formData.append("username", config.adminUsername); formData.append("password", config.adminPasswordHash); } else { formData.append("identifier", config.identifier); formData.append("secret", config.secret); } // Add action and response format formData.append("action", action); formData.append("responsetype", "json"); // Add parameters for (const [key, value] of Object.entries(params)) { if (value === undefined || value === null) { continue; } const serialized = this.serializeParamValue(value); formData.append(key, serialized); } return formData.toString(); } private serializeParamValue(value: unknown): string { if (typeof value === "string") { return value; } if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") { return String(value); } if (value instanceof Date) { return value.toISOString(); } if (Array.isArray(value)) { return value.map(entry => this.serializeParamValue(entry)).join(","); } if (typeof value === "object" && value !== null) { try { return JSON.stringify(value); } catch { return Object.prototype.toString.call(value); } } if (typeof value === "symbol") { return value.description ? `Symbol(${value.description})` : "Symbol()"; } if (typeof value === "function") { return value.name ? `[Function ${value.name}]` : "[Function anonymous]"; } return Object.prototype.toString.call(value); } /** * Parse WHMCS API response according to official documentation */ private parseResponse( responseText: string, action: string, params: Record ): WhmcsApiResponse { let parsedResponse: any; try { parsedResponse = JSON.parse(responseText); } catch (parseError) { this.logger.error(`Invalid JSON response from WHMCS API [${action}]`, { responseText: responseText.substring(0, 500), parseError: getErrorMessage(parseError), params: this.sanitizeLogParams(params), }); throw new Error("Invalid JSON response from WHMCS API"); } // Validate basic response structure if (!parsedResponse || typeof parsedResponse !== 'object') { this.logger.error(`WHMCS API returned invalid response structure [${action}]`, { responseType: typeof parsedResponse, responseText: responseText.substring(0, 500), params: this.sanitizeLogParams(params), }); throw new Error("Invalid response structure from WHMCS API"); } // Handle error responses according to WHMCS API documentation if (parsedResponse.result === "error") { const errorMessage = parsedResponse.message || parsedResponse.error || "Unknown WHMCS API error"; const errorCode = parsedResponse.errorcode || "unknown"; this.logger.error(`WHMCS API returned error [${action}]`, { errorMessage, errorCode, params: this.sanitizeLogParams(params), }); throw new Error(`WHMCS API Error: ${errorMessage} (${errorCode})`); } // For successful responses, WHMCS API returns data directly at the root level // The response structure is: { "result": "success", ...actualData } // We need to wrap this in our expected format return { result: "success", data: parsedResponse as T } as WhmcsApiResponse; } /** * Check if error should not be retried */ private shouldNotRetry(error: unknown): boolean { const message = getErrorMessage(error).toLowerCase(); // Don't retry authentication errors if (message.includes("authentication") || message.includes("unauthorized")) { return true; } // Don't retry validation errors if (message.includes("invalid") || message.includes("required")) { return true; } // Don't retry not found errors if (message.includes("not found")) { return true; } return false; } /** * Calculate retry delay with exponential backoff */ private calculateRetryDelay(attempt: number, baseDelay: number): number { const exponentialDelay = baseDelay * Math.pow(2, attempt - 1); const jitter = Math.random() * 0.1 * exponentialDelay; return Math.min(exponentialDelay + jitter, 10000); // Max 10 seconds } /** * Sleep for specified milliseconds */ private sleep(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); } /** * Update success statistics */ private updateSuccessStats(responseTime: number): void { this.stats.successfulRequests++; // Update average response time const totalSuccessful = this.stats.successfulRequests; this.stats.averageResponseTime = (this.stats.averageResponseTime * (totalSuccessful - 1) + responseTime) / totalSuccessful; } /** * Sanitize parameters for logging (remove sensitive data) */ private sanitizeLogParams(params: Record): Record { const sensitiveKeys = [ "password", "secret", "token", "key", "auth", "credit_card", "cvv", "ssn", "social_security", ]; const sanitized: Record = {}; for (const [key, value] of Object.entries(params)) { const keyLower = key.toLowerCase(); const isSensitive = sensitiveKeys.some(sensitive => keyLower.includes(sensitive)); if (isSensitive) { sanitized[key] = "[REDACTED]"; } else { sanitized[key] = value; } } return sanitized; } }