외부 REST API 커넥션 POST/Body + DB 토큰 테스트 지원
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
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 {
|
||||
@@ -30,6 +32,10 @@ export class ExternalRestApiConnectionService {
|
||||
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
|
||||
@@ -129,6 +135,8 @@ export class ExternalRestApiConnectionService {
|
||||
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
|
||||
@@ -194,9 +202,10 @@ export class ExternalRestApiConnectionService {
|
||||
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)
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
@@ -206,6 +215,8 @@ export class ExternalRestApiConnectionService {
|
||||
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,
|
||||
@@ -301,6 +312,18 @@ export class ExternalRestApiConnectionService {
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (data.default_method !== undefined) {
|
||||
updateFields.push(`default_method = $${paramIndex}`);
|
||||
params.push(data.default_method);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (data.default_body !== undefined) {
|
||||
updateFields.push(`default_request_body = $${paramIndex}`);
|
||||
params.push(data.default_body);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (data.auth_type !== undefined) {
|
||||
updateFields.push(`auth_type = $${paramIndex}`);
|
||||
params.push(data.auth_type);
|
||||
@@ -441,7 +464,8 @@ export class ExternalRestApiConnectionService {
|
||||
* REST API 연결 테스트 (테스트 요청 데이터 기반)
|
||||
*/
|
||||
static async testConnection(
|
||||
testRequest: RestApiTestRequest
|
||||
testRequest: RestApiTestRequest,
|
||||
userCompanyCode?: string
|
||||
): Promise<RestApiTestResult> {
|
||||
const startTime = Date.now();
|
||||
|
||||
@@ -450,7 +474,78 @@ export class ExternalRestApiConnectionService {
|
||||
const headers = { ...testRequest.headers };
|
||||
|
||||
// 인증 헤더 추가
|
||||
if (
|
||||
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
|
||||
) {
|
||||
@@ -493,25 +588,71 @@ export class ExternalRestApiConnectionService {
|
||||
`REST API 연결 테스트: ${testRequest.method || "GET"} ${url}`
|
||||
);
|
||||
|
||||
// HTTP 요청 실행
|
||||
const response = await fetch(url, {
|
||||
method: testRequest.method || "GET",
|
||||
headers,
|
||||
signal: AbortSignal.timeout(testRequest.timeout || 30000),
|
||||
});
|
||||
// Body 처리
|
||||
let body: any = undefined;
|
||||
if (testRequest.body) {
|
||||
// 이미 문자열이면 그대로, 객체면 JSON 문자열로 변환
|
||||
if (typeof testRequest.body === "string") {
|
||||
body = testRequest.body;
|
||||
} else {
|
||||
body = JSON.stringify(testRequest.body);
|
||||
}
|
||||
|
||||
const responseTime = Date.now() - startTime;
|
||||
let responseData = null;
|
||||
|
||||
try {
|
||||
responseData = await response.json();
|
||||
} catch {
|
||||
// JSON 파싱 실패는 무시 (텍스트 응답일 수 있음)
|
||||
// Content-Type 헤더가 없으면 기본적으로 application/json 추가
|
||||
const hasContentType = Object.keys(headers).some(
|
||||
(k) => k.toLowerCase() === "content-type"
|
||||
);
|
||||
if (!hasContentType) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
}
|
||||
}
|
||||
|
||||
// HTTP 요청 실행 (배치관리 RestApiConnector와 동일하게 TLS 검증 우회 옵션 적용)
|
||||
const httpsAgent = new https.Agent({
|
||||
// 배치관리와 동일하게, 일부 내부망/자체 서명 인증서를 사용하는 API를 위해
|
||||
// 인증서 검증을 비활성화한다.
|
||||
// 공개 인터넷용 API에는 신중히 사용해야 함.
|
||||
rejectUnauthorized: false,
|
||||
});
|
||||
|
||||
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.ok,
|
||||
message: response.ok
|
||||
success: response.status >= 200 && response.status < 300,
|
||||
message:
|
||||
response.status >= 200 && response.status < 300
|
||||
? "연결 성공"
|
||||
: `연결 실패 (${response.status} ${response.statusText})`,
|
||||
response_time: responseTime,
|
||||
@@ -552,17 +693,27 @@ export class ExternalRestApiConnectionService {
|
||||
|
||||
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,
|
||||
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);
|
||||
const result = await this.testConnection(
|
||||
testRequest,
|
||||
connection.company_code
|
||||
);
|
||||
|
||||
// 테스트 결과 저장
|
||||
await pool.query(
|
||||
@@ -709,6 +860,7 @@ export class ExternalRestApiConnectionService {
|
||||
"bearer",
|
||||
"basic",
|
||||
"oauth2",
|
||||
"db-token",
|
||||
];
|
||||
if (!validAuthTypes.includes(data.auth_type)) {
|
||||
throw new Error("올바르지 않은 인증 타입입니다.");
|
||||
|
||||
Reference in New Issue
Block a user