2025-09-25 15:11:28 +09:00
|
|
|
import { Injectable, Inject } from "@nestjs/common";
|
|
|
|
|
import { Logger } from "nestjs-pino";
|
2025-12-10 16:08:34 +09:00
|
|
|
import { getErrorMessage } from "@bff/core/utils/error.util.js";
|
|
|
|
|
import { FreebitAuthService } from "./freebit-auth.service.js";
|
|
|
|
|
import { FreebitError } from "./freebit-error.service.js";
|
2025-09-25 15:11:28 +09:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
*/
|
2025-09-25 17:42:36 +09:00
|
|
|
async makeAuthenticatedRequest<TResponse extends FreebitResponseBase, TPayload extends object>(
|
|
|
|
|
endpoint: string,
|
|
|
|
|
payload: TPayload
|
|
|
|
|
): Promise<TResponse> {
|
2025-09-25 15:11:28 +09:00
|
|
|
const authKey = await this.authService.getAuthKey();
|
|
|
|
|
const config = this.authService.getConfig();
|
2025-09-25 17:42:36 +09:00
|
|
|
|
2025-09-25 15:11:28 +09:00
|
|
|
const requestPayload = { ...payload, authKey };
|
2025-11-21 18:41:14 +09:00
|
|
|
// Ensure proper URL construction - remove double slashes
|
2025-12-10 16:08:34 +09:00
|
|
|
const baseUrl = config.baseUrl.replace(/\/$/, ""); // Remove trailing slash
|
|
|
|
|
const cleanEndpoint = endpoint.startsWith("/") ? endpoint : `/${endpoint}`;
|
2025-11-21 18:41:14 +09:00
|
|
|
const url = `${baseUrl}${cleanEndpoint}`;
|
2025-09-25 15:11:28 +09:00
|
|
|
|
|
|
|
|
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) {
|
2025-11-21 18:41:14 +09:00
|
|
|
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),
|
|
|
|
|
});
|
2025-09-25 15:11:28 +09:00
|
|
|
throw new FreebitError(
|
|
|
|
|
`HTTP ${response.status}: ${response.statusText}`,
|
|
|
|
|
response.status.toString()
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const responseData = (await response.json()) as TResponse;
|
|
|
|
|
|
2025-11-21 18:41:14 +09:00
|
|
|
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,
|
|
|
|
|
});
|
2025-12-10 16:08:34 +09:00
|
|
|
|
2025-09-25 15:11:28 +09:00
|
|
|
throw new FreebitError(
|
|
|
|
|
`API Error: ${responseData.status?.message || "Unknown error"}`,
|
2025-11-21 18:41:14 +09:00
|
|
|
resultCode,
|
|
|
|
|
statusCode,
|
2025-09-25 15:11:28 +09:00
|
|
|
responseData.status?.message
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.logger.debug("Freebit API request successful", {
|
|
|
|
|
url,
|
2025-11-21 18:41:14 +09:00
|
|
|
resultCode,
|
2025-09-25 15:11:28 +09:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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");
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-25 17:01:47 +09:00
|
|
|
/**
|
|
|
|
|
* Make an authenticated JSON request to Freebit API (for PA05-41)
|
|
|
|
|
*/
|
|
|
|
|
async makeAuthenticatedJsonRequest<
|
|
|
|
|
TResponse extends FreebitResponseBase,
|
|
|
|
|
TPayload extends object,
|
|
|
|
|
>(endpoint: string, payload: TPayload): Promise<TResponse> {
|
|
|
|
|
const config = this.authService.getConfig();
|
2025-11-21 18:41:14 +09:00
|
|
|
// Ensure proper URL construction - remove double slashes
|
2025-12-10 16:08:34 +09:00
|
|
|
const baseUrl = config.baseUrl.replace(/\/$/, ""); // Remove trailing slash
|
|
|
|
|
const cleanEndpoint = endpoint.startsWith("/") ? endpoint : `/${endpoint}`;
|
2025-11-21 18:41:14 +09:00
|
|
|
const url = `${baseUrl}${cleanEndpoint}`;
|
2025-09-25 17:01:47 +09:00
|
|
|
|
|
|
|
|
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<string, unknown>),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
2025-11-21 18:41:14 +09:00
|
|
|
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<string, unknown>),
|
|
|
|
|
attempt,
|
|
|
|
|
});
|
2025-09-25 17:01:47 +09:00
|
|
|
throw new FreebitError(
|
|
|
|
|
`API Error: ${responseData.status?.message || "Unknown error"}`,
|
2025-11-21 18:41:14 +09:00
|
|
|
resultCode,
|
|
|
|
|
statusCode,
|
2025-09-25 17:01:47 +09:00
|
|
|
responseData.status?.message
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.logger.debug("Freebit JSON API request successful", {
|
|
|
|
|
url,
|
2025-11-21 18:41:14 +09:00
|
|
|
resultCode,
|
2025-09-25 17:01:47 +09:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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);
|
2025-09-25 17:42:36 +09:00
|
|
|
this.logger.error(
|
|
|
|
|
`Freebit JSON API request failed after ${config.retryAttempts} attempts`,
|
|
|
|
|
{
|
|
|
|
|
url,
|
|
|
|
|
error: message,
|
|
|
|
|
}
|
|
|
|
|
);
|
2025-09-25 17:01:47 +09:00
|
|
|
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");
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-25 15:11:28 +09:00
|
|
|
/**
|
|
|
|
|
* Make a simple request without authentication (for health checks)
|
|
|
|
|
*/
|
|
|
|
|
async makeSimpleRequest(endpoint: string): Promise<boolean> {
|
|
|
|
|
const config = this.authService.getConfig();
|
2025-11-21 18:41:14 +09:00
|
|
|
// Ensure proper URL construction - remove double slashes
|
2025-12-10 16:08:34 +09:00
|
|
|
const baseUrl = config.baseUrl.replace(/\/$/, ""); // Remove trailing slash
|
|
|
|
|
const cleanEndpoint = endpoint.startsWith("/") ? endpoint : `/${endpoint}`;
|
2025-11-21 18:41:14 +09:00
|
|
|
const url = `${baseUrl}${cleanEndpoint}`;
|
2025-09-25 15:11:28 +09:00
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const controller = new AbortController();
|
|
|
|
|
const timeout = setTimeout(() => controller.abort(), config.timeout);
|
|
|
|
|
|
|
|
|
|
const response = await fetch(url, {
|
|
|
|
|
method: "GET",
|
|
|
|
|
signal: controller.signal,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
clearTimeout(timeout);
|
2025-09-25 17:42:36 +09:00
|
|
|
|
2025-09-25 15:11:28 +09:00
|
|
|
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<string, unknown>): Record<string, unknown> {
|
|
|
|
|
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;
|
|
|
|
|
}
|
2025-11-21 18:41:14 +09:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
2025-09-25 15:11:28 +09:00
|
|
|
}
|