From 552beabdc0e301e49d99904eec600bde9cc80c11 Mon Sep 17 00:00:00 2001 From: leeheejin Date: Fri, 28 Nov 2025 14:45:04 +0900 Subject: [PATCH] =?UTF-8?q?null=EB=A1=9C=20=EC=A0=80=EC=9E=A5=EB=90=98?= =?UTF-8?q?=EA=B2=8C=20=EC=84=B1=EA=B3=B5=EC=8B=9C=ED=82=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../routes/externalRestApiConnectionRoutes.ts | 42 +++++ .../externalRestApiConnectionService.ts | 176 +++++++++++++++++- .../admin/RestApiConnectionModal.tsx | 17 +- frontend/components/screen/ScreenDesigner.tsx | 62 ++++-- frontend/lib/api/externalRestApiConnection.ts | 37 ++++ 5 files changed, 317 insertions(+), 17 deletions(-) diff --git a/backend-node/src/routes/externalRestApiConnectionRoutes.ts b/backend-node/src/routes/externalRestApiConnectionRoutes.ts index a789b218..48813575 100644 --- a/backend-node/src/routes/externalRestApiConnectionRoutes.ts +++ b/backend-node/src/routes/externalRestApiConnectionRoutes.ts @@ -267,4 +267,46 @@ router.post( } ); +/** + * POST /api/external-rest-api-connections/:id/fetch + * REST API 데이터 조회 (화면관리용 프록시) + */ +router.post( + "/:id/fetch", + authenticateToken, + async (req: AuthenticatedRequest, res: Response) => { + try { + const id = parseInt(req.params.id); + + if (isNaN(id)) { + return res.status(400).json({ + success: false, + message: "유효하지 않은 ID입니다.", + }); + } + + const { endpoint, jsonPath } = req.body; + const userCompanyCode = req.user?.companyCode; + + logger.info(`REST API 데이터 조회 요청: 연결 ID=${id}, endpoint=${endpoint}, jsonPath=${jsonPath}`); + + const result = await ExternalRestApiConnectionService.fetchData( + id, + endpoint, + jsonPath, + userCompanyCode + ); + + return res.status(result.success ? 200 : 400).json(result); + } catch (error) { + logger.error("REST API 데이터 조회 오류:", error); + return res.status(500).json({ + success: false, + message: "서버 내부 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }); + } + } +); + export default router; diff --git a/backend-node/src/services/externalRestApiConnectionService.ts b/backend-node/src/services/externalRestApiConnectionService.ts index 668c07ae..36f3a7e2 100644 --- a/backend-node/src/services/externalRestApiConnectionService.ts +++ b/backend-node/src/services/externalRestApiConnectionService.ts @@ -166,6 +166,9 @@ export class ExternalRestApiConnectionService { ? 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, @@ -227,6 +230,15 @@ export class ExternalRestApiConnectionService { 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 = await pool.query(query, params); logger.info(`REST API 연결 생성 성공: ${data.connection_name}`); @@ -316,12 +328,14 @@ export class ExternalRestApiConnectionService { 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); + params.push(data.default_body); // null이면 DB에서 NULL로 저장됨 paramIndex++; + logger.info(`수정 요청 - default_body: ${data.default_body ? "있음" : "삭제(null)"}`); } if (data.auth_type !== undefined) { @@ -870,6 +884,166 @@ export class ExternalRestApiConnectionService { return decrypted; } + /** + * REST API 데이터 조회 (화면관리용 프록시) + * 저장된 연결 정보를 사용하여 외부 REST API를 호출하고 데이터를 반환 + */ + static async fetchData( + connectionId: number, + endpoint?: string, + jsonPath?: string, + userCompanyCode?: string + ): Promise> { + 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 : "알 수 없는 오류", + }, + }; + } + } + /** * 연결 데이터 유효성 검증 */ diff --git a/frontend/components/admin/RestApiConnectionModal.tsx b/frontend/components/admin/RestApiConnectionModal.tsx index 8795fa40..aa7d79d8 100644 --- a/frontend/components/admin/RestApiConnectionModal.tsx +++ b/frontend/components/admin/RestApiConnectionModal.tsx @@ -226,7 +226,7 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }: endpoint_path: endpointPath || undefined, default_headers: defaultHeaders, default_method: defaultMethod, - default_body: defaultBody || undefined, + default_body: defaultBody.trim() || null, // 빈 문자열이면 null로 전송하여 DB 업데이트 auth_type: authType, auth_config: authType === "none" ? undefined : authConfig, timeout, @@ -236,6 +236,13 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }: is_active: isActive ? "Y" : "N", }; + console.log("저장하려는 데이터:", { + connection_name: connectionName, + default_method: defaultMethod, + endpoint_path: endpointPath, + base_url: baseUrl, + }); + if (connection?.id) { await ExternalRestApiConnectionAPI.updateConnection(connection.id, data); toast({ @@ -303,7 +310,13 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }: 기본 URL *
- { + setDefaultMethod(val); + setTestMethod(val); // 테스트 Method도 동기화 + }} + > diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx index 46d6ab37..08199609 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -66,6 +66,7 @@ const calculateGridInfo = (width: number, height: number, settings: any) => { import { GroupingToolbar } from "./GroupingToolbar"; import { screenApi, tableTypeApi } from "@/lib/api/screen"; import { tableManagementApi } from "@/lib/api/tableManagement"; +import { ExternalRestApiConnectionAPI } from "@/lib/api/externalRestApiConnection"; import { toast } from "sonner"; import { MenuAssignmentModal } from "./MenuAssignmentModal"; import { FileAttachmentDetailModal } from "./FileAttachmentDetailModal"; @@ -835,9 +836,52 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD } }, []); - // 화면의 기본 테이블 정보 로드 (원래대로 복원) + // 화면의 기본 테이블/REST API 정보 로드 useEffect(() => { - const loadScreenTable = async () => { + const loadScreenDataSource = async () => { + // REST API 데이터 소스인 경우 + if (selectedScreen?.dataSourceType === "restapi" && selectedScreen?.restApiConnectionId) { + try { + const restApiData = await ExternalRestApiConnectionAPI.fetchData( + selectedScreen.restApiConnectionId, + selectedScreen.restApiEndpoint, + selectedScreen.restApiJsonPath || "data", + ); + + // REST API 응답에서 컬럼 정보 생성 + const columns: ColumnInfo[] = restApiData.columns.map((col) => ({ + tableName: `restapi_${selectedScreen.restApiConnectionId}`, + columnName: col.columnName, + columnLabel: col.columnLabel, + dataType: col.dataType === "string" ? "varchar" : col.dataType === "number" ? "numeric" : col.dataType, + webType: col.dataType === "number" ? "number" : "text", + input_type: "text", + widgetType: col.dataType === "number" ? "number" : "text", + isNullable: "YES", + required: false, + })); + + const tableInfo: TableInfo = { + tableName: `restapi_${selectedScreen.restApiConnectionId}`, + tableLabel: restApiData.connectionInfo.connectionName || "REST API 데이터", + columns, + }; + + setTables([tableInfo]); + console.log("REST API 데이터 소스 로드 완료:", { + connectionName: restApiData.connectionInfo.connectionName, + columnsCount: columns.length, + rowsCount: restApiData.total, + }); + } catch (error) { + console.error("REST API 데이터 소스 로드 실패:", error); + toast.error("REST API 데이터를 불러오는데 실패했습니다."); + setTables([]); + } + return; + } + + // 데이터베이스 데이터 소스인 경우 (기존 로직) const tableName = selectedScreen?.tableName; if (!tableName) { setTables([]); @@ -859,16 +903,6 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD const columns: ColumnInfo[] = (columnsResponse || []).map((col: any) => { const widgetType = col.widgetType || col.widget_type || col.webType || col.web_type; - // 🔍 이미지 타입 디버깅 - // if (widgetType === "image" || col.webType === "image" || col.web_type === "image") { - // console.log("🖼️ 이미지 컬럼 발견:", { - // columnName: col.columnName || col.column_name, - // widgetType, - // webType: col.webType || col.web_type, - // rawData: col, - // }); - // } - return { tableName: col.tableName || tableName, columnName: col.columnName || col.column_name, @@ -899,8 +933,8 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD } }; - loadScreenTable(); - }, [selectedScreen?.tableName, selectedScreen?.screenName]); + loadScreenDataSource(); + }, [selectedScreen?.tableName, selectedScreen?.screenName, selectedScreen?.dataSourceType, selectedScreen?.restApiConnectionId, selectedScreen?.restApiEndpoint, selectedScreen?.restApiJsonPath]); // 화면 레이아웃 로드 useEffect(() => { diff --git a/frontend/lib/api/externalRestApiConnection.ts b/frontend/lib/api/externalRestApiConnection.ts index c24081ba..f907ee85 100644 --- a/frontend/lib/api/externalRestApiConnection.ts +++ b/frontend/lib/api/externalRestApiConnection.ts @@ -192,6 +192,43 @@ export class ExternalRestApiConnectionAPI { return response.data; } + /** + * REST API 데이터 조회 (화면관리용 프록시) + */ + static async fetchData( + connectionId: number, + endpoint?: string, + jsonPath?: string, + ): Promise<{ + rows: any[]; + columns: Array<{ columnName: string; columnLabel: string; dataType: string }>; + total: number; + connectionInfo: { + connectionId: number; + connectionName: string; + baseUrl: string; + endpoint: string; + }; + }> { + const response = await apiClient.post; + total: number; + connectionInfo: { + connectionId: number; + connectionName: string; + baseUrl: string; + endpoint: string; + }; + }>>(`${this.BASE_PATH}/${connectionId}/fetch`, { endpoint, jsonPath }); + + if (!response.data.success) { + throw new Error(response.data.message || "REST API 데이터 조회에 실패했습니다."); + } + + return response.data.data!; + } + /** * 지원되는 인증 타입 목록 */