import { Injectable, Inject } from "@nestjs/common"; import { Logger } from "nestjs-pino"; import { getErrorMessage } from "@bff/core/utils/error.util.js"; import { FreebitAuthService } from "./freebit-auth.service.js"; import { FreebitError } from "./freebit-error.service.js"; interface FreebitResponseBase { resultCode?: string | number; status?: { message?: string; statusCode?: string | number; }; } @Injectable() export class FreebitClientService { constructor( private readonly authService: FreebitAuthService, @Inject(Logger) private readonly logger: Logger ) {} /** * Make an authenticated request to Freebit API with retry logic */ async makeAuthenticatedRequest( endpoint: string, payload: TPayload ): Promise { const authKey = await this.authService.getAuthKey(); const config = this.authService.getConfig(); const requestPayload = { ...payload, authKey }; // Ensure proper URL construction - remove double slashes const baseUrl = config.baseUrl.replace(/\/$/, ""); // Remove trailing slash const cleanEndpoint = endpoint.startsWith("/") ? endpoint : `/${endpoint}`; const url = `${baseUrl}${cleanEndpoint}`; for (let attempt = 1; attempt <= config.retryAttempts; attempt++) { try { this.logger.debug(`Freebit API request (attempt ${attempt}/${config.retryAttempts})`, { url, payload: this.sanitizePayload(requestPayload), }); const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), config.timeout); const response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: `json=${JSON.stringify(requestPayload)}`, signal: controller.signal, }); clearTimeout(timeout); if (!response.ok) { const errorText = await response.text().catch(() => "Unable to read response body"); this.logger.error(`Freebit API HTTP error`, { url, status: response.status, statusText: response.statusText, responseBody: errorText, attempt, payload: this.sanitizePayload(requestPayload), }); throw new FreebitError( `HTTP ${response.status}: ${response.statusText}`, response.status.toString() ); } const responseData = (await response.json()) as TResponse; const resultCode = this.normalizeResultCode(responseData.resultCode); const statusCode = this.normalizeResultCode(responseData.status?.statusCode); if (resultCode && resultCode !== "100") { this.logger.warn("Freebit API returned error response", { url, resultCode, statusCode, statusMessage: responseData.status?.message, fullResponse: responseData, }); throw new FreebitError( `API Error: ${responseData.status?.message || "Unknown error"}`, resultCode, statusCode, responseData.status?.message ); } this.logger.debug("Freebit API request successful", { url, resultCode, }); return responseData; } catch (error: unknown) { if (error instanceof FreebitError) { if (error.isAuthError() && attempt === 1) { this.logger.warn("Auth error detected, clearing cache and retrying"); this.authService.clearAuthCache(); continue; } if (!error.isRetryable() || attempt === config.retryAttempts) { throw error; } } if (attempt === config.retryAttempts) { const message = getErrorMessage(error); this.logger.error(`Freebit API request failed after ${config.retryAttempts} attempts`, { url, error: message, }); throw new FreebitError(`Request failed: ${message}`); } const delay = Math.min(1000 * Math.pow(2, attempt - 1), 10000); this.logger.warn(`Freebit API request failed, retrying in ${delay}ms`, { url, attempt, error: getErrorMessage(error), }); await new Promise(resolve => setTimeout(resolve, delay)); } } throw new FreebitError("Request failed after all retry attempts"); } /** * Make an authenticated JSON request to Freebit API (for PA05-41) */ async makeAuthenticatedJsonRequest< TResponse extends FreebitResponseBase, TPayload extends object, >(endpoint: string, payload: TPayload): Promise { const config = this.authService.getConfig(); // Ensure proper URL construction - remove double slashes const baseUrl = config.baseUrl.replace(/\/$/, ""); // Remove trailing slash const cleanEndpoint = endpoint.startsWith("/") ? endpoint : `/${endpoint}`; const url = `${baseUrl}${cleanEndpoint}`; for (let attempt = 1; attempt <= config.retryAttempts; attempt++) { try { this.logger.debug(`Freebit JSON API request (attempt ${attempt}/${config.retryAttempts})`, { url, payload: this.sanitizePayload(payload as Record), }); const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), config.timeout); const response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), signal: controller.signal, }); clearTimeout(timeout); if (!response.ok) { throw new FreebitError( `HTTP ${response.status}: ${response.statusText}`, response.status.toString() ); } const responseData = (await response.json()) as TResponse; const resultCode = this.normalizeResultCode(responseData.resultCode); const statusCode = this.normalizeResultCode(responseData.status?.statusCode); if (resultCode && resultCode !== "100") { this.logger.error(`Freebit API returned error result code`, { url, resultCode, statusCode, message: responseData.status?.message, responseData: this.sanitizePayload(responseData as unknown as Record), attempt, }); throw new FreebitError( `API Error: ${responseData.status?.message || "Unknown error"}`, resultCode, statusCode, responseData.status?.message ); } this.logger.debug("Freebit JSON API request successful", { url, resultCode, }); return responseData; } catch (error: unknown) { if (error instanceof FreebitError) { if (error.isAuthError() && attempt === 1) { this.logger.warn("Auth error detected, clearing cache and retrying"); this.authService.clearAuthCache(); continue; } if (!error.isRetryable() || attempt === config.retryAttempts) { throw error; } } if (attempt === config.retryAttempts) { const message = getErrorMessage(error); this.logger.error( `Freebit JSON API request failed after ${config.retryAttempts} attempts`, { url, error: message, } ); throw new FreebitError(`Request failed: ${message}`); } const delay = Math.min(1000 * Math.pow(2, attempt - 1), 10000); this.logger.warn(`Freebit JSON API request failed, retrying in ${delay}ms`, { url, attempt, error: getErrorMessage(error), }); await new Promise(resolve => setTimeout(resolve, delay)); } } throw new FreebitError("Request failed after all retry attempts"); } /** * Make a simple request without authentication (for health checks) */ async makeSimpleRequest(endpoint: string): Promise { const config = this.authService.getConfig(); // Ensure proper URL construction - remove double slashes const baseUrl = config.baseUrl.replace(/\/$/, ""); // Remove trailing slash const cleanEndpoint = endpoint.startsWith("/") ? endpoint : `/${endpoint}`; const url = `${baseUrl}${cleanEndpoint}`; try { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), config.timeout); const response = await fetch(url, { method: "GET", signal: controller.signal, }); clearTimeout(timeout); return response.ok; } catch (error) { this.logger.debug("Simple request failed", { url, error: getErrorMessage(error), }); return false; } } /** * Sanitize payload for logging (remove sensitive data) */ private sanitizePayload(payload: Record): Record { const sanitized = { ...payload }; // Remove sensitive fields const sensitiveFields = ["authKey", "oemKey", "password", "secret"]; for (const field of sensitiveFields) { if (sanitized[field]) { sanitized[field] = "[REDACTED]"; } } return sanitized; } private normalizeResultCode(code?: string | number | null): string | undefined { if (code === undefined || code === null) { return undefined; } const normalized = String(code).trim(); return normalized.length > 0 ? normalized : undefined; } }