외부 REST API 연결 확장

This commit is contained in:
dohyeons
2025-11-28 11:35:36 +09:00
parent b70ed8aaff
commit 39d327fb45
5 changed files with 308 additions and 154 deletions

View File

@@ -1,4 +1,7 @@
import { Response } from "express";
import https from "https";
import axios, { AxiosRequestConfig } from "axios";
import { logger } from "../utils/logger";
import { AuthenticatedRequest } from "../middleware/authMiddleware";
import { DashboardService } from "../services/DashboardService";
import {
@@ -7,6 +10,7 @@ import {
DashboardListQuery,
} from "../types/dashboard";
import { PostgreSQLService } from "../database/PostgreSQLService";
import { ExternalRestApiConnectionService } from "../services/externalRestApiConnectionService";
/**
* 대시보드 컨트롤러
@@ -590,7 +594,14 @@ export class DashboardController {
res: Response
): Promise<void> {
try {
const { url, method = "GET", headers = {}, queryParams = {} } = req.body;
const {
url,
method = "GET",
headers = {},
queryParams = {},
body,
externalConnectionId, // 프론트엔드에서 선택된 커넥션 ID를 전달받아야 함
} = req.body;
if (!url || typeof url !== "string") {
res.status(400).json({
@@ -608,85 +619,131 @@ export class DashboardController {
}
});
// 외부 API 호출 (타임아웃 30초)
// @ts-ignore - node-fetch dynamic import
const fetch = (await import("node-fetch")).default;
// 타임아웃 설정 (Node.js 글로벌 AbortController 사용)
const controller = new (global as any).AbortController();
const timeoutId = setTimeout(() => controller.abort(), 60000); // 60초 (기상청 API는 느림)
let response;
try {
response = await fetch(urlObj.toString(), {
method: method.toUpperCase(),
headers: {
"Content-Type": "application/json",
...headers,
},
signal: controller.signal,
});
clearTimeout(timeoutId);
} catch (err: any) {
clearTimeout(timeoutId);
if (err.name === 'AbortError') {
throw new Error('외부 API 요청 타임아웃 (30초 초과)');
// Axios 요청 설정
const requestConfig: AxiosRequestConfig = {
url: urlObj.toString(),
method: method.toUpperCase(),
headers: {
"Content-Type": "application/json",
Accept: "application/json",
...headers,
},
timeout: 60000, // 60초 타임아웃
validateStatus: () => true, // 모든 상태 코드 허용 (에러도 응답으로 처리)
};
// 외부 커넥션 ID가 있는 경우, 해당 커넥션의 인증 정보(DB 토큰 등)를 적용
if (externalConnectionId) {
try {
// 사용자 회사 코드가 있으면 사용하고, 없으면 '*' (최고 관리자)로 시도
let companyCode = req.user?.companyCode;
if (!companyCode) {
companyCode = "*";
}
// 커넥션 로드
const connectionResult =
await ExternalRestApiConnectionService.getConnectionById(
Number(externalConnectionId),
companyCode
);
if (connectionResult.success && connectionResult.data) {
const connection = connectionResult.data;
// 인증 헤더 생성 (DB 토큰 등)
const authHeaders =
await ExternalRestApiConnectionService.getAuthHeaders(
connection.auth_type,
connection.auth_config,
connection.company_code
);
// 기존 헤더에 인증 헤더 병합
requestConfig.headers = {
...requestConfig.headers,
...authHeaders,
};
// API Key가 Query Param인 경우 처리
if (
connection.auth_type === "api-key" &&
connection.auth_config?.keyLocation === "query" &&
connection.auth_config?.keyName &&
connection.auth_config?.keyValue
) {
const currentUrl = new URL(requestConfig.url!);
currentUrl.searchParams.append(
connection.auth_config.keyName,
connection.auth_config.keyValue
);
requestConfig.url = currentUrl.toString();
}
}
} catch (connError) {
logger.error(
`외부 커넥션(${externalConnectionId}) 정보 로드 및 인증 적용 실패:`,
connError
);
}
throw err;
}
if (!response.ok) {
// Body 처리
if (body) {
requestConfig.data = body;
}
// TLS 인증서 검증 예외 처리 (thiratis.com 등 내부망/레거시 API 대응)
// ExternalRestApiConnectionService와 동일한 로직 적용
const bypassDomains = ["thiratis.com"];
const hostname = urlObj.hostname;
const shouldBypassTls = bypassDomains.some((domain) =>
hostname.includes(domain)
);
if (shouldBypassTls) {
requestConfig.httpsAgent = new https.Agent({
rejectUnauthorized: false,
});
}
const response = await axios(requestConfig);
if (response.status >= 400) {
throw new Error(
`외부 API 오류: ${response.status} ${response.statusText}`
);
}
// Content-Type에 따라 응답 파싱
const contentType = response.headers.get("content-type");
let data: any;
let data = response.data;
const contentType = response.headers["content-type"];
// 한글 인코딩 처리 (EUC-KR → UTF-8)
const isKoreanApi = urlObj.hostname.includes('kma.go.kr') ||
urlObj.hostname.includes('data.go.kr');
if (isKoreanApi) {
// 한국 정부 API는 EUC-KR 인코딩 사용
const buffer = await response.arrayBuffer();
const decoder = new TextDecoder('euc-kr');
const text = decoder.decode(buffer);
try {
data = JSON.parse(text);
} catch {
data = { text, contentType };
}
} else if (contentType && contentType.includes("application/json")) {
data = await response.json();
} else if (contentType && contentType.includes("text/")) {
// 텍스트 응답 (CSV, 일반 텍스트 등)
const text = await response.text();
data = { text, contentType };
} else {
// 기타 응답 (JSON으로 시도)
try {
data = await response.json();
} catch {
const text = await response.text();
data = { text, contentType };
}
// 텍스트 응답인 경우 포맷팅
if (typeof data === "string") {
data = { text: data, contentType };
}
res.status(200).json({
success: true,
data,
});
} catch (error) {
} catch (error: any) {
const status = error.response?.status || 500;
const message = error.response?.statusText || error.message;
logger.error("외부 API 호출 오류:", {
message,
status,
data: error.response?.data,
});
res.status(500).json({
success: false,
message: "외부 API 호출 중 오류가 발생했습니다.",
error:
process.env.NODE_ENV === "development"
? (error as Error).message
? message
: "외부 API 호출 오류",
});
}

View File

@@ -460,6 +460,105 @@ export class ExternalRestApiConnectionService {
}
}
/**
* 인증 헤더 생성
*/
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 토큰 설정이 올바르지 않습니다.");
}
if (!companyCode) {
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[] = [companyCode];
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 (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;
}
}
return headers;
}
/**
* REST API 연결 테스트 (테스트 요청 데이터 기반)
*/
@@ -471,99 +570,15 @@ export class ExternalRestApiConnectionService {
try {
// 헤더 구성
const headers = { ...testRequest.headers };
let 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;
}
}
// 인증 헤더 생성 및 병합
const authHeaders = await this.getAuthHeaders(
testRequest.auth_type,
testRequest.auth_config,
userCompanyCode
);
headers = { ...headers, ...authHeaders };
// URL 구성
let url = testRequest.base_url;