Files
vexplor/backend-node/src/services/externalRestApiConnectionService.ts

1251 lines
39 KiB
TypeScript
Raw Normal View History

2025-10-21 10:59:15 +09:00
import { Pool, QueryResult } from "pg";
import axios, { AxiosResponse } from "axios";
import https from "https";
2025-10-21 10:59:15 +09:00
import { getPool } from "../database/db";
import logger from "../utils/logger";
import {
ExternalRestApiConnection,
ExternalRestApiConnectionFilter,
RestApiTestRequest,
RestApiTestResult,
AuthType,
} from "../types/externalRestApiTypes";
import { ApiResponse } from "../types/common";
import crypto from "crypto";
const pool = getPool();
// 암호화 설정
const ENCRYPTION_KEY =
process.env.DB_PASSWORD_SECRET || "default-secret-key-change-in-production";
const ALGORITHM = "aes-256-gcm";
export class ExternalRestApiConnectionService {
/**
* REST API
*/
static async getConnections(
2025-10-28 11:54:44 +09:00
filter: ExternalRestApiConnectionFilter = {},
userCompanyCode?: string
2025-10-21 10:59:15 +09:00
): Promise<ApiResponse<ExternalRestApiConnection[]>> {
try {
let query = `
SELECT
e.id, e.connection_name, e.description, e.base_url, e.endpoint_path, e.default_headers,
e.default_method,
-- DB default_request_body
-- default_body alias
e.default_request_body AS default_body,
e.auth_type, e.auth_config, e.timeout, e.retry_count, e.retry_delay,
e.company_code, e.is_active, e.created_date, e.created_by,
e.updated_date, e.updated_by, e.last_test_date, e.last_test_result, e.last_test_message,
COALESCE(c.company_name, CASE WHEN e.company_code = '*' THEN '전체' ELSE e.company_code END) AS company_name
FROM external_rest_api_connections e
LEFT JOIN company_mng c ON e.company_code = c.company_code
2025-10-21 10:59:15 +09:00
WHERE 1=1
`;
const params: any[] = [];
let paramIndex = 1;
2025-10-28 11:54:44 +09:00
// 회사별 필터링 (최고 관리자가 아닌 경우 필수)
if (userCompanyCode && userCompanyCode !== "*") {
query += ` AND e.company_code = $${paramIndex}`;
2025-10-28 11:54:44 +09:00
params.push(userCompanyCode);
2025-10-21 10:59:15 +09:00
paramIndex++;
2025-10-28 11:54:44 +09:00
logger.info(`회사별 REST API 연결 필터링: ${userCompanyCode}`);
} else if (userCompanyCode === "*") {
logger.info(`최고 관리자: 모든 REST API 연결 조회`);
// 필터가 있으면 적용
if (filter.company_code) {
query += ` AND e.company_code = $${paramIndex}`;
2025-10-28 11:54:44 +09:00
params.push(filter.company_code);
paramIndex++;
}
} else {
// userCompanyCode가 없는 경우 (하위 호환성)
if (filter.company_code) {
query += ` AND e.company_code = $${paramIndex}`;
2025-10-28 11:54:44 +09:00
params.push(filter.company_code);
paramIndex++;
}
2025-10-21 10:59:15 +09:00
}
// 활성 상태 필터
if (filter.is_active) {
query += ` AND e.is_active = $${paramIndex}`;
2025-10-21 10:59:15 +09:00
params.push(filter.is_active);
paramIndex++;
}
// 인증 타입 필터
if (filter.auth_type) {
query += ` AND e.auth_type = $${paramIndex}`;
2025-10-21 10:59:15 +09:00
params.push(filter.auth_type);
paramIndex++;
}
// 검색어 필터 (연결명, 설명, URL)
if (filter.search) {
query += ` AND (
e.connection_name ILIKE $${paramIndex} OR
e.description ILIKE $${paramIndex} OR
e.base_url ILIKE $${paramIndex}
2025-10-21 10:59:15 +09:00
)`;
params.push(`%${filter.search}%`);
paramIndex++;
}
query += ` ORDER BY created_date DESC`;
const result: QueryResult<any> = await pool.query(query, params);
// 민감 정보 복호화
const connections = result.rows.map((row: any) => ({
...row,
auth_config: row.auth_config
? this.decryptSensitiveData(row.auth_config)
: null,
}));
return {
success: true,
data: connections,
message: `${connections.length}개의 연결을 조회했습니다.`,
};
} catch (error) {
logger.error("REST API 연결 목록 조회 오류:", error);
return {
success: false,
message: "연결 목록 조회에 실패했습니다.",
error: {
code: "FETCH_ERROR",
details: error instanceof Error ? error.message : "알 수 없는 오류",
},
};
}
}
/**
* REST API
*/
static async getConnectionById(
2025-10-28 11:54:44 +09:00
id: number,
userCompanyCode?: string
2025-10-21 10:59:15 +09:00
): Promise<ApiResponse<ExternalRestApiConnection>> {
try {
2025-10-28 11:54:44 +09:00
let query = `
2025-10-21 10:59:15 +09:00
SELECT
id, connection_name, description, base_url, endpoint_path, default_headers,
default_method,
default_request_body AS default_body,
2025-10-21 10:59:15 +09:00
auth_type, auth_config, timeout, retry_count, retry_delay,
company_code, is_active, created_date, created_by,
updated_date, updated_by, last_test_date, last_test_result, last_test_message
FROM external_rest_api_connections
WHERE id = $1
`;
2025-10-28 11:54:44 +09:00
const params: any[] = [id];
// 회사별 필터링 (최고 관리자가 아닌 경우)
if (userCompanyCode && userCompanyCode !== "*") {
query += ` AND company_code = $2`;
params.push(userCompanyCode);
}
const result: QueryResult<any> = await pool.query(query, params);
2025-10-21 10:59:15 +09:00
if (result.rows.length === 0) {
return {
success: false,
2025-10-28 11:54:44 +09:00
message: "연결을 찾을 수 없거나 권한이 없습니다.",
2025-10-21 10:59:15 +09:00
};
}
const connection = result.rows[0];
connection.auth_config = connection.auth_config
? this.decryptSensitiveData(connection.auth_config)
: null;
2025-11-28 14:45:04 +09:00
// 디버깅: 조회된 연결 정보 로깅
logger.info(`REST API 연결 조회 결과 (ID: ${id}): connection_name=${connection.connection_name}, default_method=${connection.default_method}, endpoint_path=${connection.endpoint_path}`);
2025-10-21 10:59:15 +09:00
return {
success: true,
data: connection,
message: "연결을 조회했습니다.",
};
} catch (error) {
logger.error("REST API 연결 상세 조회 오류:", error);
return {
success: false,
message: "연결 조회에 실패했습니다.",
error: {
code: "FETCH_ERROR",
details: error instanceof Error ? error.message : "알 수 없는 오류",
},
};
}
}
/**
* REST API
*/
static async createConnection(
data: ExternalRestApiConnection
): Promise<ApiResponse<ExternalRestApiConnection>> {
try {
// 유효성 검증
this.validateConnectionData(data);
// 민감 정보 암호화
const encryptedAuthConfig = data.auth_config
? this.encryptSensitiveData(data.auth_config)
: null;
const query = `
INSERT INTO external_rest_api_connections (
connection_name, description, base_url, endpoint_path, default_headers,
default_method, default_request_body,
2025-10-21 10:59:15 +09:00
auth_type, auth_config, timeout, retry_count, retry_delay,
company_code, is_active, created_by, save_to_history
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
2025-10-21 10:59:15 +09:00
RETURNING *
`;
const params = [
data.connection_name,
data.description || null,
data.base_url,
data.endpoint_path || null,
2025-10-21 10:59:15 +09:00
JSON.stringify(data.default_headers || {}),
data.default_method || "GET",
data.default_body || null,
2025-10-21 10:59:15 +09:00
data.auth_type,
encryptedAuthConfig ? JSON.stringify(encryptedAuthConfig) : null,
data.timeout || 30000,
data.retry_count || 0,
data.retry_delay || 1000,
data.company_code || "*",
data.is_active || "Y",
data.created_by || "system",
data.save_to_history || "N",
2025-10-21 10:59:15 +09:00
];
2025-11-28 14:45:04 +09:00
// 디버깅: 저장하려는 데이터 로깅
logger.info(`REST API 연결 생성 요청 데이터:`, {
connection_name: data.connection_name,
company_code: data.company_code,
2025-11-28 14:45:04 +09:00
default_method: data.default_method,
endpoint_path: data.endpoint_path,
base_url: data.base_url,
default_body: data.default_body ? "있음" : "없음",
});
2025-10-21 10:59:15 +09:00
const result: QueryResult<any> = await pool.query(query, params);
logger.info(`REST API 연결 생성 성공: ${data.connection_name}`);
return {
success: true,
data: result.rows[0],
message: "연결이 생성되었습니다.",
};
} catch (error: any) {
logger.error("REST API 연결 생성 오류:", error);
// 중복 키 오류 처리
if (error.code === "23505") {
return {
success: false,
message: "이미 존재하는 연결명입니다.",
};
}
return {
success: false,
message: "연결 생성에 실패했습니다.",
error: {
code: "CREATE_ERROR",
details: error instanceof Error ? error.message : "알 수 없는 오류",
},
};
}
}
/**
* REST API
*/
static async updateConnection(
id: number,
2025-10-28 11:54:44 +09:00
data: Partial<ExternalRestApiConnection>,
userCompanyCode?: string
2025-10-21 10:59:15 +09:00
): Promise<ApiResponse<ExternalRestApiConnection>> {
try {
2025-10-28 11:54:44 +09:00
// 기존 연결 확인 (회사 코드로 권한 체크)
const existing = await this.getConnectionById(id, userCompanyCode);
2025-10-21 10:59:15 +09:00
if (!existing.success) {
return existing;
}
// 민감 정보 암호화
const encryptedAuthConfig = data.auth_config
? this.encryptSensitiveData(data.auth_config)
: undefined;
const updateFields: string[] = [];
const params: any[] = [];
let paramIndex = 1;
if (data.connection_name !== undefined) {
updateFields.push(`connection_name = $${paramIndex}`);
params.push(data.connection_name);
paramIndex++;
}
if (data.description !== undefined) {
updateFields.push(`description = $${paramIndex}`);
params.push(data.description);
paramIndex++;
}
if (data.base_url !== undefined) {
updateFields.push(`base_url = $${paramIndex}`);
params.push(data.base_url);
paramIndex++;
}
if (data.endpoint_path !== undefined) {
updateFields.push(`endpoint_path = $${paramIndex}`);
params.push(data.endpoint_path);
paramIndex++;
}
2025-10-21 10:59:15 +09:00
if (data.default_headers !== undefined) {
updateFields.push(`default_headers = $${paramIndex}`);
params.push(JSON.stringify(data.default_headers));
paramIndex++;
}
if (data.default_method !== undefined) {
updateFields.push(`default_method = $${paramIndex}`);
params.push(data.default_method);
paramIndex++;
2025-11-28 14:45:04 +09:00
logger.info(`수정 요청 - default_method: ${data.default_method}`);
}
if (data.default_body !== undefined) {
updateFields.push(`default_request_body = $${paramIndex}`);
2025-11-28 14:45:04 +09:00
params.push(data.default_body); // null이면 DB에서 NULL로 저장됨
paramIndex++;
2025-11-28 14:45:04 +09:00
logger.info(`수정 요청 - default_body: ${data.default_body ? "있음" : "삭제(null)"}`);
}
2025-10-21 10:59:15 +09:00
if (data.auth_type !== undefined) {
updateFields.push(`auth_type = $${paramIndex}`);
params.push(data.auth_type);
paramIndex++;
}
if (encryptedAuthConfig !== undefined) {
updateFields.push(`auth_config = $${paramIndex}`);
params.push(JSON.stringify(encryptedAuthConfig));
paramIndex++;
}
if (data.timeout !== undefined) {
updateFields.push(`timeout = $${paramIndex}`);
params.push(data.timeout);
paramIndex++;
}
if (data.retry_count !== undefined) {
updateFields.push(`retry_count = $${paramIndex}`);
params.push(data.retry_count);
paramIndex++;
}
if (data.retry_delay !== undefined) {
updateFields.push(`retry_delay = $${paramIndex}`);
params.push(data.retry_delay);
paramIndex++;
}
if (data.is_active !== undefined) {
updateFields.push(`is_active = $${paramIndex}`);
params.push(data.is_active);
paramIndex++;
}
if (data.save_to_history !== undefined) {
updateFields.push(`save_to_history = $${paramIndex}`);
params.push(data.save_to_history);
paramIndex++;
}
2025-10-21 10:59:15 +09:00
if (data.updated_by !== undefined) {
updateFields.push(`updated_by = $${paramIndex}`);
params.push(data.updated_by);
paramIndex++;
}
updateFields.push(`updated_date = NOW()`);
params.push(id);
const query = `
UPDATE external_rest_api_connections
SET ${updateFields.join(", ")}
WHERE id = $${paramIndex}
RETURNING *
`;
const result: QueryResult<any> = await pool.query(query, params);
logger.info(`REST API 연결 수정 성공: ID ${id}`);
return {
success: true,
data: result.rows[0],
message: "연결이 수정되었습니다.",
};
} catch (error: any) {
logger.error("REST API 연결 수정 오류:", error);
if (error.code === "23505") {
return {
success: false,
message: "이미 존재하는 연결명입니다.",
};
}
return {
success: false,
message: "연결 수정에 실패했습니다.",
error: {
code: "UPDATE_ERROR",
details: error instanceof Error ? error.message : "알 수 없는 오류",
},
};
}
}
/**
* REST API
*/
2025-10-28 11:54:44 +09:00
static async deleteConnection(
id: number,
userCompanyCode?: string
): Promise<ApiResponse<void>> {
2025-10-21 10:59:15 +09:00
try {
2025-10-28 11:54:44 +09:00
let query = `
2025-10-21 10:59:15 +09:00
DELETE FROM external_rest_api_connections
WHERE id = $1
`;
2025-10-28 11:54:44 +09:00
const params: any[] = [id];
// 회사별 필터링 (최고 관리자가 아닌 경우)
if (userCompanyCode && userCompanyCode !== "*") {
query += ` AND company_code = $2`;
params.push(userCompanyCode);
}
query += ` RETURNING connection_name`;
const result: QueryResult<any> = await pool.query(query, params);
2025-10-21 10:59:15 +09:00
if (result.rows.length === 0) {
return {
success: false,
2025-10-28 11:54:44 +09:00
message: "연결을 찾을 수 없거나 권한이 없습니다.",
2025-10-21 10:59:15 +09:00
};
}
2025-10-28 11:54:44 +09:00
logger.info(
`REST API 연결 삭제 성공: ${result.rows[0].connection_name} (회사: ${userCompanyCode || "전체"})`
);
2025-10-21 10:59:15 +09:00
return {
success: true,
message: "연결이 삭제되었습니다.",
};
} catch (error) {
logger.error("REST API 연결 삭제 오류:", error);
return {
success: false,
message: "연결 삭제에 실패했습니다.",
error: {
code: "DELETE_ERROR",
details: error instanceof Error ? error.message : "알 수 없는 오류",
},
};
}
}
/**
2025-11-28 11:35:36 +09:00
*
2025-10-21 10:59:15 +09:00
*/
2025-11-28 11:35:36 +09:00
static async getAuthHeaders(
authType: AuthType,
authConfig: any,
companyCode?: string
): Promise<Record<string, string>> {
const headers: Record<string, string> = {};
if (authType === "db-token") {
const cfg = authConfig || {};
const {
dbTableName,
dbValueColumn,
dbWhereColumn,
dbWhereValue,
dbHeaderName,
dbHeaderTemplate,
} = cfg;
if (!dbTableName || !dbValueColumn) {
throw new Error("DB 토큰 설정이 올바르지 않습니다.");
}
2025-10-21 10:59:15 +09:00
2025-11-28 11:35:36 +09:00
if (!companyCode) {
throw new Error("DB 토큰 모드에서는 회사 코드가 필요합니다.");
}
2025-11-28 11:35:36 +09:00
const hasWhereColumn = !!dbWhereColumn;
const hasWhereValue =
dbWhereValue !== undefined &&
dbWhereValue !== null &&
dbWhereValue !== "";
// where 컬럼/값은 둘 다 비우거나 둘 다 채워야 함
if (hasWhereColumn !== hasWhereValue) {
throw new Error(
"DB 토큰 설정에서 조건 컬럼과 조건 값은 둘 다 비우거나 둘 다 입력해야 합니다."
);
}
2025-11-28 11:35:36 +09:00
// 식별자 검증 (간단한 화이트리스트)
const identifierRegex = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
if (
!identifierRegex.test(dbTableName) ||
!identifierRegex.test(dbValueColumn) ||
(hasWhereColumn && !identifierRegex.test(dbWhereColumn as string))
) {
throw new Error(
"DB 토큰 설정에 유효하지 않은 테이블 또는 컬럼명이 포함되어 있습니다."
);
}
2025-11-28 11:35:36 +09:00
let sql = `
SELECT ${dbValueColumn} AS token_value
FROM ${dbTableName}
WHERE company_code = $1
`;
2025-11-28 11:35:36 +09:00
const params: any[] = [companyCode];
2025-11-28 11:35:36 +09:00
if (hasWhereColumn && hasWhereValue) {
sql += ` AND ${dbWhereColumn} = $2`;
params.push(dbWhereValue);
}
2025-11-28 11:35:36 +09:00
sql += `
ORDER BY updated_date DESC
LIMIT 1
`;
2025-11-28 11:35:36 +09:00
const tokenResult: QueryResult<any> = await pool.query(sql, params);
2025-11-28 11:35:36 +09:00
if (tokenResult.rowCount === 0) {
throw new Error("DB에서 토큰을 찾을 수 없습니다.");
}
2025-11-28 11:35:36 +09:00
const tokenValue = tokenResult.rows[0]["token_value"];
const headerName = dbHeaderName || "Authorization";
const template = dbHeaderTemplate || "Bearer {{value}}";
headers[headerName] = template.replace("{{value}}", tokenValue);
} else if (authType === "bearer" && authConfig?.token) {
headers["Authorization"] = `Bearer ${authConfig.token}`;
} else if (authType === "basic" && authConfig) {
const credentials = Buffer.from(
`${authConfig.username}:${authConfig.password}`
).toString("base64");
headers["Authorization"] = `Basic ${credentials}`;
} else if (authType === "api-key" && authConfig) {
if (authConfig.keyLocation === "header") {
headers[authConfig.keyName] = authConfig.keyValue;
}
}
2025-11-28 11:35:36 +09:00
return headers;
}
2025-11-28 11:35:36 +09:00
/**
* REST API ( )
*/
static async testConnection(
testRequest: RestApiTestRequest,
userCompanyCode?: string
): Promise<RestApiTestResult> {
const startTime = Date.now();
2025-11-28 11:35:36 +09:00
try {
// 헤더 구성
let headers = { ...testRequest.headers };
// 인증 헤더 생성 및 병합
const authHeaders = await this.getAuthHeaders(
testRequest.auth_type,
testRequest.auth_config,
userCompanyCode
);
headers = { ...headers, ...authHeaders };
2025-10-21 10:59:15 +09:00
// URL 구성
let url = testRequest.base_url;
if (testRequest.endpoint) {
url = testRequest.endpoint.startsWith("/")
? `${testRequest.base_url}${testRequest.endpoint}`
: `${testRequest.base_url}/${testRequest.endpoint}`;
}
// API Key가 쿼리에 있는 경우
if (
testRequest.auth_type === "api-key" &&
testRequest.auth_config?.keyLocation === "query" &&
testRequest.auth_config?.keyName &&
testRequest.auth_config?.keyValue
) {
const separator = url.includes("?") ? "&" : "?";
url = `${url}${separator}${testRequest.auth_config.keyName}=${testRequest.auth_config.keyValue}`;
}
logger.info(
`REST API 연결 테스트: ${testRequest.method || "GET"} ${url}`
);
// Body 처리
let body: any = undefined;
if (testRequest.body) {
// 이미 문자열이면 그대로, 객체면 JSON 문자열로 변환
if (typeof testRequest.body === "string") {
body = testRequest.body;
} else {
body = JSON.stringify(testRequest.body);
}
// Content-Type 헤더가 없으면 기본적으로 application/json 추가
const hasContentType = Object.keys(headers).some(
(k) => k.toLowerCase() === "content-type"
);
if (!hasContentType) {
headers["Content-Type"] = "application/json";
}
}
// HTTP 요청 실행
// [인수인계 중요] 2024-11-27 추가
// 특정 레거시/내부망 API(예: thiratis.com)의 경우 SSL 인증서 체인 문제로 인해
// Node.js 레벨에서 검증 실패(UNABLE_TO_VERIFY_LEAF_SIGNATURE)가 발생합니다.
//
// 원래는 인프라(OS/Docker)에 루트 CA를 등록하는 것이 정석이나,
// 유지보수 및 설정 편의성을 위해 코드 레벨에서 '특정 도메인'에 한해서만
// SSL 검증을 우회하도록 예외 처리를 해두었습니다.
//
// ※ 보안 주의: 여기에 모르는 도메인을 함부로 추가하면 중간자 공격(MITM)에 취약해질 수 있습니다.
// 꼭 필요한 신뢰할 수 있는 도메인만 추가하세요.
const bypassDomains = ["thiratis.com"];
const shouldBypassTls = bypassDomains.some((domain) =>
url.includes(domain)
);
const httpsAgent = new https.Agent({
// bypassDomains에 포함된 URL이면 검증을 무시(false), 아니면 정상 검증(true)
rejectUnauthorized: !shouldBypassTls,
2025-10-21 10:59:15 +09:00
});
const requestConfig = {
url,
method: (testRequest.method || "GET") as any,
headers,
data: body,
httpsAgent,
timeout: testRequest.timeout || 30000,
// 4xx/5xx 도 예외가 아니라 응답 객체로 처리
validateStatus: () => true,
};
2025-10-21 10:59:15 +09:00
// 요청 상세 로그 (민감 정보는 최소화)
logger.info(
`REST API 연결 테스트 요청 상세: ${JSON.stringify({
method: requestConfig.method,
url: requestConfig.url,
headers: {
...requestConfig.headers,
// Authorization 헤더는 마스킹
Authorization: requestConfig.headers?.Authorization
? "***masked***"
: undefined,
},
hasBody: !!body,
})}`
);
const response: AxiosResponse = await axios.request(requestConfig);
const responseTime = Date.now() - startTime;
// axios는 response.data에 이미 파싱된 응답 본문을 담아준다.
// JSON이 아니어도 그대로 내려보내서 프론트에서 확인할 수 있게 한다.
const responseData = response.data ?? null;
2025-10-21 10:59:15 +09:00
return {
success: response.status >= 200 && response.status < 300,
message:
response.status >= 200 && response.status < 300
2025-10-21 10:59:15 +09:00
? "연결 성공"
: `연결 실패 (${response.status} ${response.statusText})`,
response_time: responseTime,
status_code: response.status,
response_data: responseData,
};
} catch (error) {
const responseTime = Date.now() - startTime;
logger.error("REST API 연결 테스트 오류:", error);
return {
success: false,
message: "연결 실패",
response_time: responseTime,
error_details:
error instanceof Error ? error.message : "알 수 없는 오류",
};
}
}
/**
* REST API (ID )
*/
static async testConnectionById(
id: number,
endpoint?: string
): Promise<RestApiTestResult> {
try {
const connectionResult = await this.getConnectionById(id);
if (!connectionResult.success || !connectionResult.data) {
return {
success: false,
message: "연결을 찾을 수 없습니다.",
};
}
const connection = connectionResult.data;
// 리스트에서 endpoint를 넘기지 않으면,
// 저장된 endpoint_path를 기본 엔드포인트로 사용
const effectiveEndpoint =
endpoint || connection.endpoint_path || undefined;
2025-10-21 10:59:15 +09:00
const testRequest: RestApiTestRequest = {
id: connection.id,
base_url: connection.base_url,
endpoint: effectiveEndpoint,
method: (connection.default_method as any) || "GET", // 기본 메서드 적용
2025-10-21 10:59:15 +09:00
headers: connection.default_headers,
body: connection.default_body, // 기본 바디 적용
2025-10-21 10:59:15 +09:00
auth_type: connection.auth_type,
auth_config: connection.auth_config,
timeout: connection.timeout,
};
const result = await this.testConnection(
testRequest,
connection.company_code
);
2025-10-21 10:59:15 +09:00
// 테스트 결과 저장
await pool.query(
`
UPDATE external_rest_api_connections
SET
last_test_date = NOW(),
last_test_result = $1,
last_test_message = $2
WHERE id = $3
`,
[result.success ? "Y" : "N", result.message, id]
);
return result;
} catch (error) {
logger.error("REST API 연결 테스트 (ID) 오류:", error);
const errorMessage =
error instanceof Error ? error.message : "알 수 없는 오류";
// 예외가 발생한 경우에도 마지막 테스트 결과를 실패로 기록
try {
await pool.query(
`
UPDATE external_rest_api_connections
SET
last_test_date = NOW(),
last_test_result = $1,
last_test_message = $2
WHERE id = $3
`,
["N", errorMessage, id]
);
} catch (updateError) {
logger.error(
"REST API 연결 테스트 (ID) 오류 기록 실패:",
updateError
);
}
2025-10-21 10:59:15 +09:00
return {
success: false,
message: "연결 테스트에 실패했습니다.",
error_details: errorMessage,
2025-10-21 10:59:15 +09:00
};
}
}
/**
*
*/
private static encryptSensitiveData(authConfig: any): any {
if (!authConfig) return null;
const encrypted = { ...authConfig };
// 암호화 대상 필드
if (encrypted.keyValue) {
encrypted.keyValue = this.encrypt(encrypted.keyValue);
}
if (encrypted.token) {
encrypted.token = this.encrypt(encrypted.token);
}
if (encrypted.password) {
encrypted.password = this.encrypt(encrypted.password);
}
if (encrypted.clientSecret) {
encrypted.clientSecret = this.encrypt(encrypted.clientSecret);
}
return encrypted;
}
/**
*
*/
private static decryptSensitiveData(authConfig: any): any {
if (!authConfig) return null;
const decrypted = { ...authConfig };
// 복호화 대상 필드
try {
if (decrypted.keyValue) {
decrypted.keyValue = this.decrypt(decrypted.keyValue);
}
if (decrypted.token) {
decrypted.token = this.decrypt(decrypted.token);
}
if (decrypted.password) {
decrypted.password = this.decrypt(decrypted.password);
}
if (decrypted.clientSecret) {
decrypted.clientSecret = this.decrypt(decrypted.clientSecret);
}
} catch (error) {
logger.warn("민감 정보 복호화 실패 (암호화되지 않은 데이터일 수 있음)");
}
return decrypted;
}
/**
*
*/
private static encrypt(text: string): string {
const iv = crypto.randomBytes(16);
const key = crypto.scryptSync(ENCRYPTION_KEY, "salt", 32);
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
let encrypted = cipher.update(text, "utf8", "hex");
encrypted += cipher.final("hex");
const authTag = cipher.getAuthTag();
return `${iv.toString("hex")}:${authTag.toString("hex")}:${encrypted}`;
}
/**
*
*/
private static decrypt(text: string): string {
const parts = text.split(":");
if (parts.length !== 3) {
// 암호화되지 않은 데이터
return text;
}
const iv = Buffer.from(parts[0], "hex");
const authTag = Buffer.from(parts[1], "hex");
const encryptedText = parts[2];
const key = crypto.scryptSync(ENCRYPTION_KEY, "salt", 32);
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(encryptedText, "hex", "utf8");
decrypted += decipher.final("utf8");
return decrypted;
}
2025-11-28 14:45:04 +09:00
/**
* REST API ( )
* REST API를
*/
static async fetchData(
connectionId: number,
endpoint?: string,
jsonPath?: string,
userCompanyCode?: string
): Promise<ApiResponse<any>> {
try {
// 연결 정보 조회
const connectionResult = await this.getConnectionById(connectionId, userCompanyCode);
if (!connectionResult.success || !connectionResult.data) {
return {
success: false,
message: "REST API 연결을 찾을 수 없습니다.",
error: {
code: "CONNECTION_NOT_FOUND",
details: `연결 ID ${connectionId}를 찾을 수 없습니다.`,
},
};
}
const connection = connectionResult.data;
// 비활성화된 연결인지 확인
if (connection.is_active !== "Y") {
return {
success: false,
message: "비활성화된 REST API 연결입니다.",
error: {
code: "CONNECTION_INACTIVE",
details: "연결이 비활성화 상태입니다.",
},
};
}
// 엔드포인트 결정 (파라미터 > 저장된 값)
const effectiveEndpoint = endpoint || connection.endpoint_path || "";
// API 호출을 위한 테스트 요청 생성
const testRequest: RestApiTestRequest = {
id: connection.id,
base_url: connection.base_url,
endpoint: effectiveEndpoint,
method: (connection.default_method as any) || "GET",
headers: connection.default_headers,
body: connection.default_body,
auth_type: connection.auth_type,
auth_config: connection.auth_config,
timeout: connection.timeout,
};
// API 호출
const result = await this.testConnection(testRequest, connection.company_code);
if (!result.success) {
return {
success: false,
message: result.message || "REST API 호출에 실패했습니다.",
error: {
code: "API_CALL_FAILED",
details: result.error_details,
},
};
}
// 응답 데이터에서 jsonPath로 데이터 추출
let extractedData = result.response_data;
logger.info(`REST API 원본 응답 데이터 타입: ${typeof result.response_data}`);
logger.info(`REST API 원본 응답 데이터 (일부): ${JSON.stringify(result.response_data)?.substring(0, 500)}`);
if (jsonPath && result.response_data) {
try {
// jsonPath로 데이터 추출 (예: "data", "data.items", "result.list")
const pathParts = jsonPath.split(".");
logger.info(`JSON Path 파싱: ${jsonPath} -> [${pathParts.join(", ")}]`);
for (const part of pathParts) {
if (extractedData && typeof extractedData === "object") {
extractedData = (extractedData as any)[part];
logger.info(`JSON Path '${part}' 추출 결과 타입: ${typeof extractedData}, 배열?: ${Array.isArray(extractedData)}`);
} else {
logger.warn(`JSON Path '${part}' 추출 실패: extractedData가 객체가 아님`);
break;
}
}
} catch (pathError) {
logger.warn(`JSON Path 추출 실패: ${jsonPath}`, pathError);
// 추출 실패 시 원본 데이터 반환
extractedData = result.response_data;
}
}
// 데이터가 배열이 아닌 경우 배열로 변환
// null이나 undefined인 경우 빈 배열로 처리
let dataArray: any[] = [];
if (extractedData === null || extractedData === undefined) {
logger.warn("추출된 데이터가 null/undefined입니다. 원본 응답 데이터를 사용합니다.");
// jsonPath 추출 실패 시 원본 데이터에서 직접 컬럼 추출 시도
if (result.response_data && typeof result.response_data === "object") {
dataArray = Array.isArray(result.response_data) ? result.response_data : [result.response_data];
}
} else {
dataArray = Array.isArray(extractedData) ? extractedData : [extractedData];
}
logger.info(`최종 데이터 배열 길이: ${dataArray.length}`);
if (dataArray.length > 0) {
logger.info(`첫 번째 데이터 항목: ${JSON.stringify(dataArray[0])?.substring(0, 300)}`);
}
// 컬럼 정보 추출 (첫 번째 유효한 데이터 기준)
let columns: Array<{ columnName: string; columnLabel: string; dataType: string }> = [];
// 첫 번째 유효한 객체 찾기
const firstValidItem = dataArray.find(item => item && typeof item === "object" && !Array.isArray(item));
if (firstValidItem) {
columns = Object.keys(firstValidItem).map((key) => ({
columnName: key,
columnLabel: key,
dataType: typeof firstValidItem[key],
}));
logger.info(`추출된 컬럼 수: ${columns.length}, 컬럼명: [${columns.map(c => c.columnName).join(", ")}]`);
} else {
logger.warn("유효한 데이터 항목을 찾을 수 없어 컬럼을 추출할 수 없습니다.");
}
return {
success: true,
data: {
rows: dataArray,
columns,
total: dataArray.length,
connectionInfo: {
connectionId: connection.id,
connectionName: connection.connection_name,
baseUrl: connection.base_url,
endpoint: effectiveEndpoint,
},
},
message: `${dataArray.length}개의 데이터를 조회했습니다.`,
};
} catch (error) {
logger.error("REST API 데이터 조회 오류:", error);
return {
success: false,
message: "REST API 데이터 조회에 실패했습니다.",
error: {
code: "FETCH_ERROR",
details: error instanceof Error ? error.message : "알 수 없는 오류",
},
};
}
}
2025-10-21 10:59:15 +09:00
/**
*
*/
private static validateConnectionData(data: ExternalRestApiConnection): void {
if (!data.connection_name || data.connection_name.trim() === "") {
throw new Error("연결명은 필수입니다.");
}
if (!data.base_url || data.base_url.trim() === "") {
throw new Error("기본 URL은 필수입니다.");
}
// URL 형식 검증
try {
new URL(data.base_url);
} catch {
throw new Error("올바른 URL 형식이 아닙니다.");
}
// 인증 타입 검증
const validAuthTypes: AuthType[] = [
"none",
"api-key",
"bearer",
"basic",
"oauth2",
"db-token",
2025-10-21 10:59:15 +09:00
];
if (!validAuthTypes.includes(data.auth_type)) {
throw new Error("올바르지 않은 인증 타입입니다.");
}
}
/**
* REST API
* REST API의
*/
static async fetchMultipleData(
configs: Array<{
connectionId: number;
endpoint: string;
jsonPath: string;
alias: string;
}>,
userCompanyCode?: string
): Promise<ApiResponse<{
rows: any[];
columns: Array<{ columnName: string; columnLabel: string; dataType: string; sourceApi: string }>;
total: number;
sources: Array<{ connectionId: number; connectionName: string; rowCount: number }>;
}>> {
try {
logger.info(`다중 REST API 데이터 조회 시작: ${configs.length}개 API`);
// 각 API에서 데이터 조회
const results = await Promise.all(
configs.map(async (config) => {
try {
const result = await this.fetchData(
config.connectionId,
config.endpoint,
config.jsonPath,
userCompanyCode
);
if (result.success && result.data) {
return {
success: true,
connectionId: config.connectionId,
connectionName: result.data.connectionInfo.connectionName,
alias: config.alias,
rows: result.data.rows,
columns: result.data.columns,
};
} else {
logger.warn(`API ${config.connectionId} 조회 실패:`, result.message);
return {
success: false,
connectionId: config.connectionId,
connectionName: "",
alias: config.alias,
rows: [],
columns: [],
error: result.message,
};
}
} catch (error) {
logger.error(`API ${config.connectionId} 조회 오류:`, error);
return {
success: false,
connectionId: config.connectionId,
connectionName: "",
alias: config.alias,
rows: [],
columns: [],
error: error instanceof Error ? error.message : "알 수 없는 오류",
};
}
})
);
// 성공한 결과만 필터링
const successfulResults = results.filter(r => r.success);
if (successfulResults.length === 0) {
return {
success: false,
message: "모든 REST API 조회에 실패했습니다.",
error: {
code: "ALL_APIS_FAILED",
details: results.map(r => ({ connectionId: r.connectionId, error: r.error })),
},
};
}
// 컬럼 병합 (별칭 적용)
const mergedColumns: Array<{ columnName: string; columnLabel: string; dataType: string; sourceApi: string }> = [];
for (const result of successfulResults) {
for (const col of result.columns) {
const prefixedColumnName = result.alias ? `${result.alias}${col.columnName}` : col.columnName;
mergedColumns.push({
columnName: prefixedColumnName,
columnLabel: `${col.columnLabel} (${result.connectionName})`,
dataType: col.dataType,
sourceApi: result.connectionName,
});
}
}
// 데이터 병합 (가로 병합: 각 API의 첫 번째 행끼리 병합)
// 참고: 실제 사용 시에는 조인 키가 필요할 수 있음
const maxRows = Math.max(...successfulResults.map(r => r.rows.length));
const mergedRows: any[] = [];
for (let i = 0; i < maxRows; i++) {
const mergedRow: any = {};
for (const result of successfulResults) {
const row = result.rows[i] || {};
for (const [key, value] of Object.entries(row)) {
const prefixedKey = result.alias ? `${result.alias}${key}` : key;
mergedRow[prefixedKey] = value;
}
}
mergedRows.push(mergedRow);
}
logger.info(`다중 REST API 데이터 병합 완료: ${mergedRows.length}개 행, ${mergedColumns.length}개 컬럼`);
return {
success: true,
data: {
rows: mergedRows,
columns: mergedColumns,
total: mergedRows.length,
sources: successfulResults.map(r => ({
connectionId: r.connectionId,
connectionName: r.connectionName,
rowCount: r.rows.length,
})),
},
message: `${successfulResults.length}개 API에서 총 ${mergedRows.length}개 데이터를 조회했습니다.`,
};
} catch (error) {
logger.error("다중 REST API 데이터 조회 오류:", error);
return {
success: false,
message: "다중 REST API 데이터 조회에 실패했습니다.",
error: {
code: "MULTI_FETCH_ERROR",
details: error instanceof Error ? error.message : "알 수 없는 오류",
},
};
}
}
2025-10-21 10:59:15 +09:00
}