From bccb8a6330b9c2021ab92a589afe2ca8419707e1 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Thu, 11 Dec 2025 10:48:48 +0900 Subject: [PATCH] =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EC=9C=84?= =?UTF-8?q?=EC=A0=AF=20REST=20API=20=EA=B8=B0=EB=8A=A5=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/controllers/DashboardController.ts | 9 ++ .../admin/dashboard/WidgetConfigSidebar.tsx | 11 +- .../dashboard/data-sources/ApiConfig.tsx | 130 ++++++++++++++++-- .../admin/dashboard/widgets/ListWidget.tsx | 34 ++++- .../dashboard/widgets/ListTestWidget.tsx | 52 +++++-- 5 files changed, 211 insertions(+), 25 deletions(-) diff --git a/backend-node/src/controllers/DashboardController.ts b/backend-node/src/controllers/DashboardController.ts index a03478b9..d1328bcd 100644 --- a/backend-node/src/controllers/DashboardController.ts +++ b/backend-node/src/controllers/DashboardController.ts @@ -702,6 +702,15 @@ export class DashboardController { requestConfig.data = body; } + // 디버깅 로그: 실제 요청 정보 출력 + logger.info(`[fetchExternalApi] 요청 정보:`, { + url: requestConfig.url, + method: requestConfig.method, + headers: requestConfig.headers, + body: requestConfig.data, + externalConnectionId, + }); + // TLS 인증서 검증 예외 처리 (thiratis.com 등 내부망/레거시 API 대응) // ExternalRestApiConnectionService와 동일한 로직 적용 const bypassDomains = ["thiratis.com"]; diff --git a/frontend/components/admin/dashboard/WidgetConfigSidebar.tsx b/frontend/components/admin/dashboard/WidgetConfigSidebar.tsx index 10af48e8..b675ca86 100644 --- a/frontend/components/admin/dashboard/WidgetConfigSidebar.tsx +++ b/frontend/components/admin/dashboard/WidgetConfigSidebar.tsx @@ -296,13 +296,20 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge element.subtype === "custom-metric-v2" || element.subtype === "risk-alert-v2"; + // 리스트 위젯이 단일 데이터 소스 UI를 사용하는 경우, dataSource를 dataSources로 변환 + let finalDataSources = dataSources; + if (isMultiDataSourceWidget && element.subtype === "list-v2" && dataSources.length === 0 && dataSource.endpoint) { + // 단일 데이터 소스가 설정되어 있으면 dataSources 배열로 변환 + finalDataSources = [dataSource]; + } + // chartConfig 구성 (위젯 타입별로 다르게 처리) let finalChartConfig = { ...chartConfig }; if (isMultiDataSourceWidget) { finalChartConfig = { ...finalChartConfig, - dataSources: dataSources, + dataSources: finalDataSources, }; } @@ -325,7 +332,7 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge // 다중 데이터 소스 위젯은 dataSources도 포함 (빈 배열도 허용 - 연결 해제) ...(isMultiDataSourceWidget ? { - dataSources: dataSources, + dataSources: finalDataSources, } : {}), } diff --git a/frontend/components/admin/dashboard/data-sources/ApiConfig.tsx b/frontend/components/admin/dashboard/data-sources/ApiConfig.tsx index 21693911..b7ba7f77 100644 --- a/frontend/components/admin/dashboard/data-sources/ApiConfig.tsx +++ b/frontend/components/admin/dashboard/data-sources/ApiConfig.tsx @@ -137,9 +137,18 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps } updates.type = "api"; // ⭐ 중요: type을 api로 명시 - updates.method = "GET"; // 기본 메서드 + updates.method = (connection.default_method as "GET" | "POST" | "PUT" | "PATCH" | "DELETE") || "GET"; // 커넥션에 설정된 메서드 사용 updates.headers = headers; updates.queryParams = queryParams; + + // Request Body가 있으면 적용 + if (connection.default_body) { + updates.body = connection.default_body; + } + + // 외부 커넥션 ID 저장 (백엔드에서 인증 정보 조회용) + updates.externalConnectionId = connection.id; + console.log("최종 업데이트:", updates); onChange(updates); @@ -254,6 +263,19 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps } }); + // 요청 메서드 결정 + const requestMethod = dataSource.method || "GET"; + + // Request Body 파싱 (POST, PUT, PATCH인 경우) + let requestBody: any = undefined; + if (["POST", "PUT", "PATCH"].includes(requestMethod) && dataSource.body) { + try { + requestBody = JSON.parse(dataSource.body); + } catch { + throw new Error("Request Body가 올바른 JSON 형식이 아닙니다"); + } + } + // 백엔드 프록시를 통한 외부 API 호출 (CORS 우회) const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), { method: "POST", @@ -262,9 +284,11 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps }, body: JSON.stringify({ url: dataSource.endpoint, - method: "GET", + method: requestMethod, headers: headers, queryParams: params, + body: requestBody, + externalConnectionId: dataSource.externalConnectionId, // DB 토큰 등 인증 정보 조회용 }), }); @@ -314,10 +338,23 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps if (dataSource.jsonPath) { const paths = dataSource.jsonPath.split("."); for (const path of paths) { - if (data && typeof data === "object" && path in data) { - data = data[path]; + // 배열인 경우 인덱스 접근, 객체인 경우 키 접근 + if (data === null || data === undefined) { + throw new Error(`JSON Path "${dataSource.jsonPath}"에서 데이터를 찾을 수 없습니다 (null/undefined)`); + } + + if (Array.isArray(data)) { + // 배열인 경우 숫자 인덱스로 접근 시도 + const index = parseInt(path); + if (!isNaN(index) && index >= 0 && index < data.length) { + data = data[index]; + } else { + throw new Error(`JSON Path "${dataSource.jsonPath}"에서 배열 인덱스 "${path}"를 찾을 수 없습니다`); + } + } else if (typeof data === "object" && path in data) { + data = (data as Record)[path]; } else { - throw new Error(`JSON Path "${dataSource.jsonPath}"에서 데이터를 찾을 수 없습니다`); + throw new Error(`JSON Path "${dataSource.jsonPath}"에서 "${path}" 키를 찾을 수 없습니다`); } } } @@ -331,6 +368,16 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps // 컬럼 추출 및 타입 분석 const firstRow = rows[0]; + + // firstRow가 null이거나 객체가 아닌 경우 처리 + if (firstRow === null || firstRow === undefined) { + throw new Error("API 응답의 첫 번째 행이 비어있습니다"); + } + + if (typeof firstRow !== "object" || Array.isArray(firstRow)) { + throw new Error("API 응답 데이터가 올바른 객체 형식이 아닙니다"); + } + const columns = Object.keys(firstRow); // 각 컬럼의 타입 분석 @@ -400,21 +447,54 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps

저장한 REST API 설정을 불러올 수 있습니다

- {/* API URL */} + {/* HTTP 메서드 및 API URL */}
- onChange({ endpoint: e.target.value })} - className="h-8 text-xs" - /> +
+ + onChange({ endpoint: e.target.value })} + className="h-8 flex-1 text-xs" + /> +

전체 URL 또는 base_url 이후 경로를 입력하세요 (외부 커넥션 선택 시 base_url 자동 입력)

+ {/* Request Body (POST, PUT, PATCH인 경우) */} + {["POST", "PUT", "PATCH"].includes(dataSource.method || "") && ( +
+ +