1080 lines
34 KiB
TypeScript
1080 lines
34 KiB
TypeScript
import { Pool, QueryResult } from "pg";
|
|
import axios, { AxiosResponse } from "axios";
|
|
import https from "https";
|
|
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(
|
|
filter: ExternalRestApiConnectionFilter = {},
|
|
userCompanyCode?: string
|
|
): Promise<ApiResponse<ExternalRestApiConnection[]>> {
|
|
try {
|
|
let query = `
|
|
SELECT
|
|
id, connection_name, description, base_url, endpoint_path, default_headers,
|
|
default_method,
|
|
-- DB 스키마의 컬럼명은 default_request_body 기준이고
|
|
-- 코드에서는 default_body 필드로 사용하기 위해 alias 처리
|
|
default_request_body AS default_body,
|
|
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 1=1
|
|
`;
|
|
|
|
const params: any[] = [];
|
|
let paramIndex = 1;
|
|
|
|
// 회사별 필터링 (최고 관리자가 아닌 경우 필수)
|
|
if (userCompanyCode && userCompanyCode !== "*") {
|
|
query += ` AND company_code = $${paramIndex}`;
|
|
params.push(userCompanyCode);
|
|
paramIndex++;
|
|
logger.info(`회사별 REST API 연결 필터링: ${userCompanyCode}`);
|
|
} else if (userCompanyCode === "*") {
|
|
logger.info(`최고 관리자: 모든 REST API 연결 조회`);
|
|
// 필터가 있으면 적용
|
|
if (filter.company_code) {
|
|
query += ` AND company_code = $${paramIndex}`;
|
|
params.push(filter.company_code);
|
|
paramIndex++;
|
|
}
|
|
} else {
|
|
// userCompanyCode가 없는 경우 (하위 호환성)
|
|
if (filter.company_code) {
|
|
query += ` AND company_code = $${paramIndex}`;
|
|
params.push(filter.company_code);
|
|
paramIndex++;
|
|
}
|
|
}
|
|
|
|
// 활성 상태 필터
|
|
if (filter.is_active) {
|
|
query += ` AND is_active = $${paramIndex}`;
|
|
params.push(filter.is_active);
|
|
paramIndex++;
|
|
}
|
|
|
|
// 인증 타입 필터
|
|
if (filter.auth_type) {
|
|
query += ` AND auth_type = $${paramIndex}`;
|
|
params.push(filter.auth_type);
|
|
paramIndex++;
|
|
}
|
|
|
|
// 검색어 필터 (연결명, 설명, URL)
|
|
if (filter.search) {
|
|
query += ` AND (
|
|
connection_name ILIKE $${paramIndex} OR
|
|
description ILIKE $${paramIndex} OR
|
|
base_url ILIKE $${paramIndex}
|
|
)`;
|
|
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(
|
|
id: number,
|
|
userCompanyCode?: string
|
|
): Promise<ApiResponse<ExternalRestApiConnection>> {
|
|
try {
|
|
let query = `
|
|
SELECT
|
|
id, connection_name, description, base_url, endpoint_path, default_headers,
|
|
default_method,
|
|
default_request_body AS default_body,
|
|
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
|
|
`;
|
|
|
|
const params: any[] = [id];
|
|
|
|
// 회사별 필터링 (최고 관리자가 아닌 경우)
|
|
if (userCompanyCode && userCompanyCode !== "*") {
|
|
query += ` AND company_code = $2`;
|
|
params.push(userCompanyCode);
|
|
}
|
|
|
|
const result: QueryResult<any> = await pool.query(query, params);
|
|
|
|
if (result.rows.length === 0) {
|
|
return {
|
|
success: false,
|
|
message: "연결을 찾을 수 없거나 권한이 없습니다.",
|
|
};
|
|
}
|
|
|
|
const connection = result.rows[0];
|
|
connection.auth_config = connection.auth_config
|
|
? this.decryptSensitiveData(connection.auth_config)
|
|
: null;
|
|
|
|
// 디버깅: 조회된 연결 정보 로깅
|
|
logger.info(`REST API 연결 조회 결과 (ID: ${id}): connection_name=${connection.connection_name}, default_method=${connection.default_method}, endpoint_path=${connection.endpoint_path}`);
|
|
|
|
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,
|
|
auth_type, auth_config, timeout, retry_count, retry_delay,
|
|
company_code, is_active, created_by
|
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
|
|
RETURNING *
|
|
`;
|
|
|
|
const params = [
|
|
data.connection_name,
|
|
data.description || null,
|
|
data.base_url,
|
|
data.endpoint_path || null,
|
|
JSON.stringify(data.default_headers || {}),
|
|
data.default_method || "GET",
|
|
data.default_body || null,
|
|
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",
|
|
];
|
|
|
|
// 디버깅: 저장하려는 데이터 로깅
|
|
logger.info(`REST API 연결 생성 요청 데이터:`, {
|
|
connection_name: data.connection_name,
|
|
default_method: data.default_method,
|
|
endpoint_path: data.endpoint_path,
|
|
base_url: data.base_url,
|
|
default_body: data.default_body ? "있음" : "없음",
|
|
});
|
|
|
|
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,
|
|
data: Partial<ExternalRestApiConnection>,
|
|
userCompanyCode?: string
|
|
): Promise<ApiResponse<ExternalRestApiConnection>> {
|
|
try {
|
|
// 기존 연결 확인 (회사 코드로 권한 체크)
|
|
const existing = await this.getConnectionById(id, userCompanyCode);
|
|
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++;
|
|
}
|
|
|
|
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++;
|
|
logger.info(`수정 요청 - default_method: ${data.default_method}`);
|
|
}
|
|
|
|
if (data.default_body !== undefined) {
|
|
updateFields.push(`default_request_body = $${paramIndex}`);
|
|
params.push(data.default_body); // null이면 DB에서 NULL로 저장됨
|
|
paramIndex++;
|
|
logger.info(`수정 요청 - default_body: ${data.default_body ? "있음" : "삭제(null)"}`);
|
|
}
|
|
|
|
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.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 연결 삭제
|
|
*/
|
|
static async deleteConnection(
|
|
id: number,
|
|
userCompanyCode?: string
|
|
): Promise<ApiResponse<void>> {
|
|
try {
|
|
let query = `
|
|
DELETE FROM external_rest_api_connections
|
|
WHERE id = $1
|
|
`;
|
|
|
|
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);
|
|
|
|
if (result.rows.length === 0) {
|
|
return {
|
|
success: false,
|
|
message: "연결을 찾을 수 없거나 권한이 없습니다.",
|
|
};
|
|
}
|
|
|
|
logger.info(
|
|
`REST API 연결 삭제 성공: ${result.rows[0].connection_name} (회사: ${userCompanyCode || "전체"})`
|
|
);
|
|
|
|
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 : "알 수 없는 오류",
|
|
},
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* REST API 연결 테스트 (테스트 요청 데이터 기반)
|
|
*/
|
|
static async testConnection(
|
|
testRequest: RestApiTestRequest,
|
|
userCompanyCode?: string
|
|
): Promise<RestApiTestResult> {
|
|
const startTime = Date.now();
|
|
|
|
try {
|
|
// 헤더 구성
|
|
const headers = { ...testRequest.headers };
|
|
|
|
// 인증 헤더 추가
|
|
if (testRequest.auth_type === "db-token") {
|
|
const cfg = testRequest.auth_config || {};
|
|
const {
|
|
dbTableName,
|
|
dbValueColumn,
|
|
dbWhereColumn,
|
|
dbWhereValue,
|
|
dbHeaderName,
|
|
dbHeaderTemplate,
|
|
} = cfg;
|
|
|
|
if (!dbTableName || !dbValueColumn) {
|
|
throw new Error("DB 토큰 설정이 올바르지 않습니다.");
|
|
}
|
|
|
|
if (!userCompanyCode) {
|
|
throw new Error("DB 토큰 모드에서는 회사 코드가 필요합니다.");
|
|
}
|
|
|
|
const hasWhereColumn = !!dbWhereColumn;
|
|
const hasWhereValue =
|
|
dbWhereValue !== undefined && dbWhereValue !== null && dbWhereValue !== "";
|
|
|
|
// where 컬럼/값은 둘 다 비우거나 둘 다 채워야 함
|
|
if (hasWhereColumn !== hasWhereValue) {
|
|
throw new Error(
|
|
"DB 토큰 설정에서 조건 컬럼과 조건 값은 둘 다 비우거나 둘 다 입력해야 합니다."
|
|
);
|
|
}
|
|
|
|
// 식별자 검증 (간단한 화이트리스트)
|
|
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 토큰 설정에 유효하지 않은 테이블 또는 컬럼명이 포함되어 있습니다."
|
|
);
|
|
}
|
|
|
|
let sql = `
|
|
SELECT ${dbValueColumn} AS token_value
|
|
FROM ${dbTableName}
|
|
WHERE company_code = $1
|
|
`;
|
|
|
|
const params: any[] = [userCompanyCode];
|
|
|
|
if (hasWhereColumn && hasWhereValue) {
|
|
sql += ` AND ${dbWhereColumn} = $2`;
|
|
params.push(dbWhereValue);
|
|
}
|
|
|
|
sql += `
|
|
ORDER BY updated_date DESC
|
|
LIMIT 1
|
|
`;
|
|
|
|
const tokenResult: QueryResult<any> = await pool.query(sql, params);
|
|
|
|
if (tokenResult.rowCount === 0) {
|
|
throw new Error("DB에서 토큰을 찾을 수 없습니다.");
|
|
}
|
|
|
|
const tokenValue = tokenResult.rows[0]["token_value"];
|
|
const headerName = dbHeaderName || "Authorization";
|
|
const template = dbHeaderTemplate || "Bearer {{value}}";
|
|
|
|
headers[headerName] = template.replace("{{value}}", tokenValue);
|
|
} else if (
|
|
testRequest.auth_type === "bearer" &&
|
|
testRequest.auth_config?.token
|
|
) {
|
|
headers["Authorization"] = `Bearer ${testRequest.auth_config.token}`;
|
|
} else if (testRequest.auth_type === "basic" && testRequest.auth_config) {
|
|
const credentials = Buffer.from(
|
|
`${testRequest.auth_config.username}:${testRequest.auth_config.password}`
|
|
).toString("base64");
|
|
headers["Authorization"] = `Basic ${credentials}`;
|
|
} else if (
|
|
testRequest.auth_type === "api-key" &&
|
|
testRequest.auth_config
|
|
) {
|
|
if (testRequest.auth_config.keyLocation === "header") {
|
|
headers[testRequest.auth_config.keyName] =
|
|
testRequest.auth_config.keyValue;
|
|
}
|
|
}
|
|
|
|
// 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,
|
|
});
|
|
|
|
const requestConfig = {
|
|
url,
|
|
method: (testRequest.method || "GET") as any,
|
|
headers,
|
|
data: body,
|
|
httpsAgent,
|
|
timeout: testRequest.timeout || 30000,
|
|
// 4xx/5xx 도 예외가 아니라 응답 객체로 처리
|
|
validateStatus: () => true,
|
|
};
|
|
|
|
// 요청 상세 로그 (민감 정보는 최소화)
|
|
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;
|
|
|
|
return {
|
|
success: response.status >= 200 && response.status < 300,
|
|
message:
|
|
response.status >= 200 && response.status < 300
|
|
? "연결 성공"
|
|
: `연결 실패 (${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;
|
|
|
|
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,
|
|
};
|
|
|
|
const result = await this.testConnection(
|
|
testRequest,
|
|
connection.company_code
|
|
);
|
|
|
|
// 테스트 결과 저장
|
|
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
|
|
);
|
|
}
|
|
|
|
return {
|
|
success: false,
|
|
message: "연결 테스트에 실패했습니다.",
|
|
error_details: errorMessage,
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 민감 정보 암호화
|
|
*/
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* 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 : "알 수 없는 오류",
|
|
},
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 연결 데이터 유효성 검증
|
|
*/
|
|
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",
|
|
];
|
|
if (!validAuthTypes.includes(data.auth_type)) {
|
|
throw new Error("올바르지 않은 인증 타입입니다.");
|
|
}
|
|
}
|
|
}
|