432 lines
12 KiB
TypeScript
Raw Normal View History

import { Injectable, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { getErrorMessage } from "@bff/core/utils/error.util.js";
import type { WhmcsResponse } from "@customer-portal/domain/common/providers";
import type {
WhmcsApiConfig,
WhmcsRequestOptions,
WhmcsConnectionStats,
} from "../types/connection.types.js";
/**
* 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<WhmcsResponse<T>> {
const startTime = Date.now();
this.stats.totalRequests++;
this.stats.lastRequestTime = new Date();
try {
const response = await this.executeRequest<T>(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<T>(
config: WhmcsApiConfig,
action: string,
params: Record<string, unknown>,
options: WhmcsRequestOptions
): Promise<WhmcsResponse<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;
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<WhmcsResponse<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);
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) {
// Do NOT include response body in thrown error messages (could contain sensitive/PII and
// would propagate into unified exception logs). If needed, emit a short snippet only in dev.
if (process.env.NODE_ENV !== "production") {
const snippet = responseText?.slice(0, 300);
if (snippet) {
this.logger.debug(`WHMCS non-OK response body snippet [${action}]`, {
action,
status: response.status,
statusText: response.statusText,
snippet,
});
}
}
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return this.parseResponse<T>(responseText, action, params);
} finally {
clearTimeout(timeoutId);
}
}
/**
* Build request body for WHMCS API
* Uses API key authentication (identifier + secret)
*/
private buildRequestBody(
config: WhmcsApiConfig,
action: string,
params: Record<string, unknown>
): string {
const formData = new URLSearchParams();
// Add API key authentication
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;
}
this.appendFormParam(formData, key, value);
}
return formData.toString();
}
private appendFormParam(formData: URLSearchParams, key: string, value: unknown): void {
if (Array.isArray(value)) {
value.forEach((entry, index) => {
this.appendFormParam(formData, `${key}[${index}]`, entry);
});
return;
}
if (value && typeof value === "object" && !(value instanceof Date)) {
// WHMCS does not accept nested objects; serialize to JSON for logging/debug.
try {
formData.append(key, JSON.stringify(value));
return;
} catch {
formData.append(key, Object.prototype.toString.call(value));
return;
}
}
formData.append(key, this.serializeScalarParam(value));
}
private serializeScalarParam(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 (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<T>(
responseText: string,
action: string,
params: Record<string, unknown>
): WhmcsResponse<T> {
let parsedResponse: unknown;
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 (!this.isWhmcsResponse(parsedResponse)) {
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
// Return the error response instead of throwing - let the orchestrator handle it
// This allows the error handler to properly classify errors (NotFoundException, etc.)
if (parsedResponse.result === "error") {
const { message, error, errorcode } = parsedResponse;
const errorMessage = this.toDisplayString(message ?? error, "Unknown WHMCS API error");
const errorCode = this.toDisplayString(errorcode, "unknown");
// Many WHMCS "result=error" responses are expected business outcomes (e.g. invalid credentials).
// Log as warning (not error) to avoid spamming error logs; the unified exception filter will
// still emit the request-level log based on the mapped error code.
this.logger.warn(`WHMCS API returned error [${action}]`, {
errorMessage,
errorCode,
params: this.sanitizeLogParams(params),
});
// Return error response for the orchestrator to handle with proper exception types
return {
result: "error" as const,
message: errorMessage,
errorcode: errorCode,
} as WhmcsResponse<T>;
}
// For successful responses, WHMCS API returns data directly at the root level
// The response structure is: { "result": "success", ...actualData }
// We return the parsed response directly as T since it contains the actual data
const { result, message, ...rest } = parsedResponse;
return {
result,
message: typeof message === "string" ? message : undefined,
data: rest as T,
} satisfies WhmcsResponse<T>;
}
private isWhmcsResponse(value: unknown): value is {
result: "success" | "error";
message?: unknown;
error?: unknown;
errorcode?: unknown;
} & Record<string, unknown> {
if (!value || typeof value !== "object") {
return false;
}
const record = value as Record<string, unknown>;
const rawResult = record.result;
return rawResult === "success" || rawResult === "error";
}
private toDisplayString(value: unknown, fallback: string): string {
if (typeof value === "string" && value.trim().length > 0) {
return value;
}
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
if (value && typeof value === "object") {
try {
return JSON.stringify(value);
} catch {
return fallback;
}
}
return fallback;
}
/**
* 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<void> {
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<string, unknown>): Record<string, unknown> {
const sensitiveKeys = [
"password",
"secret",
"token",
"key",
"auth",
"credit_card",
"cvv",
"ssn",
"social_security",
];
const sanitized: Record<string, unknown> = {};
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;
}
}