2025-09-25 15:14:36 +09:00
|
|
|
import { Injectable, Inject } from "@nestjs/common";
|
|
|
|
|
import { Logger } from "nestjs-pino";
|
|
|
|
|
import { getErrorMessage } from "@bff/core/utils/error.util";
|
2025-09-25 17:42:36 +09:00
|
|
|
import type { WhmcsApiResponse, WhmcsErrorResponse } from "../../types/whmcs-api.types";
|
|
|
|
|
import type {
|
|
|
|
|
WhmcsApiConfig,
|
2025-09-25 15:14:36 +09:00
|
|
|
WhmcsRequestOptions,
|
2025-09-25 17:42:36 +09:00
|
|
|
WhmcsConnectionStats,
|
2025-09-25 15:14:36 +09:00
|
|
|
} 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<T>(
|
|
|
|
|
config: WhmcsApiConfig,
|
|
|
|
|
action: string,
|
|
|
|
|
params: Record<string, unknown>,
|
|
|
|
|
options: WhmcsRequestOptions = {}
|
|
|
|
|
): Promise<WhmcsApiResponse<T>> {
|
|
|
|
|
const startTime = Date.now();
|
|
|
|
|
this.stats.totalRequests++;
|
|
|
|
|
this.stats.lastRequestTime = new Date();
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const response = await this.executeRequest<T>(config, action, params, options);
|
2025-09-25 17:42:36 +09:00
|
|
|
|
2025-09-25 15:14:36 +09:00
|
|
|
const responseTime = Date.now() - startTime;
|
|
|
|
|
this.updateSuccessStats(responseTime);
|
2025-09-25 17:42:36 +09:00
|
|
|
|
2025-09-25 15:14:36 +09:00
|
|
|
return response;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
this.stats.failedRequests++;
|
|
|
|
|
this.stats.lastErrorTime = new Date();
|
2025-09-25 17:42:36 +09:00
|
|
|
|
2025-09-25 15:14:36 +09:00
|
|
|
this.logger.error(`WHMCS HTTP request failed [${action}]`, {
|
|
|
|
|
error: getErrorMessage(error),
|
|
|
|
|
action,
|
|
|
|
|
params: this.sanitizeLogParams(params),
|
|
|
|
|
responseTime: Date.now() - startTime,
|
|
|
|
|
});
|
2025-09-25 17:42:36 +09:00
|
|
|
|
2025-09-25 15:14:36 +09:00
|
|
|
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<T>(
|
|
|
|
|
config: WhmcsApiConfig,
|
|
|
|
|
action: string,
|
|
|
|
|
params: Record<string, unknown>,
|
|
|
|
|
options: WhmcsRequestOptions
|
|
|
|
|
): Promise<WhmcsApiResponse<T>> {
|
|
|
|
|
const maxAttempts = options.retryAttempts ?? config.retryAttempts ?? 3;
|
|
|
|
|
let lastError: Error;
|
|
|
|
|
|
|
|
|
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
|
|
|
try {
|
|
|
|
|
return await this.performSingleRequest<T>(config, action, params, options);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
lastError = error as Error;
|
2025-09-25 17:42:36 +09:00
|
|
|
|
2025-09-25 15:14:36 +09:00
|
|
|
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<T>(
|
|
|
|
|
config: WhmcsApiConfig,
|
|
|
|
|
action: string,
|
|
|
|
|
params: Record<string, unknown>,
|
|
|
|
|
options: WhmcsRequestOptions
|
|
|
|
|
): Promise<WhmcsApiResponse<T>> {
|
|
|
|
|
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<T>(responseText, action, params);
|
|
|
|
|
} finally {
|
|
|
|
|
clearTimeout(timeoutId);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Build request body for WHMCS API
|
|
|
|
|
*/
|
|
|
|
|
private buildRequestBody(
|
|
|
|
|
config: WhmcsApiConfig,
|
|
|
|
|
action: string,
|
|
|
|
|
params: Record<string, unknown>,
|
|
|
|
|
options: WhmcsRequestOptions
|
|
|
|
|
): string {
|
|
|
|
|
const formData = new URLSearchParams();
|
2025-09-25 17:42:36 +09:00
|
|
|
|
2025-09-25 15:14:36 +09:00
|
|
|
// 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)) {
|
2025-09-25 18:59:07 +09:00
|
|
|
if (value === undefined || value === null) {
|
|
|
|
|
continue;
|
2025-09-25 15:14:36 +09:00
|
|
|
}
|
2025-09-25 18:59:07 +09:00
|
|
|
|
|
|
|
|
const serialized = this.serializeParamValue(value);
|
|
|
|
|
formData.append(key, serialized);
|
2025-09-25 15:14:36 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return formData.toString();
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-25 18:59:07 +09:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-25 15:14:36 +09:00
|
|
|
/**
|
|
|
|
|
* Parse WHMCS API response
|
|
|
|
|
*/
|
|
|
|
|
private parseResponse<T>(
|
|
|
|
|
responseText: string,
|
|
|
|
|
action: string,
|
|
|
|
|
params: Record<string, unknown>
|
|
|
|
|
): WhmcsApiResponse<T> {
|
|
|
|
|
let data: WhmcsApiResponse<T>;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
data = JSON.parse(responseText) as WhmcsApiResponse<T>;
|
|
|
|
|
} 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");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (data.result === "error") {
|
|
|
|
|
const errorResponse = data as WhmcsErrorResponse;
|
2025-09-25 17:42:36 +09:00
|
|
|
throw new Error(
|
|
|
|
|
`WHMCS API Error: ${errorResponse.message} (${errorResponse.errorcode || "unknown"})`
|
|
|
|
|
);
|
2025-09-25 15:14:36 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return data;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Check if error should not be retried
|
|
|
|
|
*/
|
|
|
|
|
private shouldNotRetry(error: unknown): boolean {
|
|
|
|
|
const message = getErrorMessage(error).toLowerCase();
|
2025-09-25 17:42:36 +09:00
|
|
|
|
2025-09-25 15:14:36 +09:00
|
|
|
// Don't retry authentication errors
|
2025-09-25 17:42:36 +09:00
|
|
|
if (message.includes("authentication") || message.includes("unauthorized")) {
|
2025-09-25 15:14:36 +09:00
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Don't retry validation errors
|
2025-09-25 17:42:36 +09:00
|
|
|
if (message.includes("invalid") || message.includes("required")) {
|
2025-09-25 15:14:36 +09:00
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Don't retry not found errors
|
2025-09-25 17:42:36 +09:00
|
|
|
if (message.includes("not found")) {
|
2025-09-25 15:14:36 +09:00
|
|
|
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<void> {
|
|
|
|
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Update success statistics
|
|
|
|
|
*/
|
|
|
|
|
private updateSuccessStats(responseTime: number): void {
|
|
|
|
|
this.stats.successfulRequests++;
|
2025-09-25 17:42:36 +09:00
|
|
|
|
2025-09-25 15:14:36 +09:00
|
|
|
// Update average response time
|
|
|
|
|
const totalSuccessful = this.stats.successfulRequests;
|
2025-09-25 17:42:36 +09:00
|
|
|
this.stats.averageResponseTime =
|
2025-09-25 15:14:36 +09:00
|
|
|
(this.stats.averageResponseTime * (totalSuccessful - 1) + responseTime) / totalSuccessful;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Sanitize parameters for logging (remove sensitive data)
|
|
|
|
|
*/
|
|
|
|
|
private sanitizeLogParams(params: Record<string, unknown>): Record<string, unknown> {
|
|
|
|
|
const sensitiveKeys = [
|
2025-09-25 17:42:36 +09:00
|
|
|
"password",
|
|
|
|
|
"secret",
|
|
|
|
|
"token",
|
|
|
|
|
"key",
|
|
|
|
|
"auth",
|
|
|
|
|
"credit_card",
|
|
|
|
|
"cvv",
|
|
|
|
|
"ssn",
|
|
|
|
|
"social_security",
|
2025-09-25 15:14:36 +09:00
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const sanitized: Record<string, unknown> = {};
|
2025-09-25 17:42:36 +09:00
|
|
|
|
2025-09-25 15:14:36 +09:00
|
|
|
for (const [key, value] of Object.entries(params)) {
|
|
|
|
|
const keyLower = key.toLowerCase();
|
|
|
|
|
const isSensitive = sensitiveKeys.some(sensitive => keyLower.includes(sensitive));
|
2025-09-25 17:42:36 +09:00
|
|
|
|
2025-09-25 15:14:36 +09:00
|
|
|
if (isSensitive) {
|
2025-09-25 17:42:36 +09:00
|
|
|
sanitized[key] = "[REDACTED]";
|
2025-09-25 15:14:36 +09:00
|
|
|
} else {
|
|
|
|
|
sanitized[key] = value;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return sanitized;
|
|
|
|
|
}
|
|
|
|
|
}
|