From f3c5c90d7b10deca91c0c36b44518e40de1d4772 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Thu, 27 Nov 2025 16:42:48 +0900 Subject: [PATCH] =?UTF-8?q?=EC=99=B8=EB=B6=80=20REST=20API=20=EC=BB=A4?= =?UTF-8?q?=EB=84=A5=EC=85=98=20POST/Body=20+=20DB=20=ED=86=A0=ED=81=B0=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=A7=80=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PLAN.MD | 42 ++-- .../routes/externalRestApiConnectionRoutes.ts | 5 +- .../externalRestApiConnectionService.ts | 192 ++++++++++++++++-- .../src/types/externalRestApiTypes.ts | 25 ++- .../components/admin/AuthenticationConfig.tsx | 89 ++++++++ .../admin/RestApiConnectionModal.tsx | 151 ++++++++++++-- frontend/lib/api/externalRestApiConnection.ts | 27 ++- 7 files changed, 469 insertions(+), 62 deletions(-) diff --git a/PLAN.MD b/PLAN.MD index 787bef69..507695c6 100644 --- a/PLAN.MD +++ b/PLAN.MD @@ -1,28 +1,36 @@ -# 프로젝트: Digital Twin 에디터 안정화 +# 프로젝트: 외부 REST API 커넥션 관리 확장 (POST/Body 지원) ## 개요 - -Digital Twin 에디터(`DigitalTwinEditor.tsx`)에서 발생한 런타임 에러(`TypeError: Cannot read properties of undefined`)를 수정하고, 전반적인 안정성을 확보합니다. +현재 GET 방식 위주로 구현된 외부 REST API 커넥션 관리 기능을 확장하여, POST, PUT, DELETE 등 다양한 HTTP 메서드와 JSON Request Body를 설정하고 테스트할 수 있도록 개선합니다. 이를 통해 토큰 발급 API나 데이터 전송 API 등 다양한 외부 시스템과의 연동을 지원합니다. ## 핵심 기능 - -1. `DigitalTwinEditor` 버그 수정 -2. 비동기 함수 입력값 유효성 검증 강화 -3. 외부 DB 연결 상태에 따른 방어 코드 추가 +1. **DB 스키마 확장**: `external_rest_api_connections` 테이블에 `default_method`, `default_body` 컬럼 추가 +2. **백엔드 로직 개선**: + - 커넥션 생성/수정 시 메서드와 바디 정보 저장 + - 연결 테스트 시 설정된 메서드와 바디를 사용하여 요청 수행 + - SSL 인증서 검증 우회 옵션 적용 (내부망/테스트망 지원) +3. **프론트엔드 UI 개선**: + - 커넥션 설정 모달에 HTTP 메서드 선택(Select) 및 Body 입력(Textarea/JSON Editor) 필드 추가 + - 테스트 기능에서 Body 데이터 포함하여 요청 전송 ## 테스트 계획 +### 1단계: 기본 기능 및 DB 마이그레이션 +- [x] DB 마이그레이션 스크립트 작성 및 실행 +- [x] 백엔드 타입 정의 수정 (`default_method`, `default_body` 추가) -### 1단계: 긴급 버그 수정 +### 2단계: 백엔드 로직 구현 +- [x] 커넥션 생성/수정 API 수정 (필드 추가) +- [x] 커넥션 상세 조회 API 확인 +- [x] 연결 테스트 API 수정 (Method, Body 반영하여 요청 전송) -- [x] `loadMaterialCountsForLocations` 함수에서 `locaKeys` undefined 체크 추가 (완료) -- [ ] 에디터 로드 및 객체 조작 시 에러 발생 여부 확인 +### 3단계: 프론트엔드 구현 +- [x] 커넥션 관리 리스트/모달 UI 수정 +- [x] 연결 테스트 UI 수정 및 기능 확인 -### 2단계: 잠재적 문제 점검 - -- [ ] `loadLayout` 등 주요 로딩 함수의 데이터 유효성 검사 -- [ ] `handleToolDragStart`, `handleCanvasDrop` 등 인터랙션 함수의 예외 처리 +## 에러 처리 계획 +- **JSON 파싱 에러**: Body 입력값이 유효한 JSON이 아닐 경우 에러 처리 +- **API 호출 에러**: 외부 API 호출 실패 시 상세 로그 기록 및 클라이언트에 에러 메시지 전달 +- **SSL 인증 에러**: `rejectUnauthorized: false` 옵션으로 처리 (기존 `RestApiConnector` 활용) ## 진행 상태 - -- [진행중] 1단계 긴급 버그 수정 완료 후 사용자 피드백 대기 중 - +- [완료] 모든 단계 구현 완료 diff --git a/backend-node/src/routes/externalRestApiConnectionRoutes.ts b/backend-node/src/routes/externalRestApiConnectionRoutes.ts index 9f577e52..a789b218 100644 --- a/backend-node/src/routes/externalRestApiConnectionRoutes.ts +++ b/backend-node/src/routes/externalRestApiConnectionRoutes.ts @@ -213,7 +213,10 @@ router.post( } const result = - await ExternalRestApiConnectionService.testConnection(testRequest); + await ExternalRestApiConnectionService.testConnection( + testRequest, + req.user?.companyCode + ); return res.status(200).json(result); } catch (error) { diff --git a/backend-node/src/services/externalRestApiConnectionService.ts b/backend-node/src/services/externalRestApiConnectionService.ts index 28eac869..a3d79ac3 100644 --- a/backend-node/src/services/externalRestApiConnectionService.ts +++ b/backend-node/src/services/externalRestApiConnectionService.ts @@ -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 { 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 = 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("올바르지 않은 인증 타입입니다."); diff --git a/backend-node/src/types/externalRestApiTypes.ts b/backend-node/src/types/externalRestApiTypes.ts index 35877974..8d95a4a6 100644 --- a/backend-node/src/types/externalRestApiTypes.ts +++ b/backend-node/src/types/externalRestApiTypes.ts @@ -1,6 +1,12 @@ // 외부 REST API 연결 관리 타입 정의 -export type AuthType = "none" | "api-key" | "bearer" | "basic" | "oauth2"; +export type AuthType = + | "none" + | "api-key" + | "bearer" + | "basic" + | "oauth2" + | "db-token"; export interface ExternalRestApiConnection { id?: number; @@ -9,6 +15,11 @@ export interface ExternalRestApiConnection { base_url: string; endpoint_path?: string; default_headers: Record; + + // 기본 메서드 및 바디 추가 + default_method?: string; + default_body?: string; + auth_type: AuthType; auth_config?: { // API Key @@ -28,6 +39,14 @@ export interface ExternalRestApiConnection { clientSecret?: string; tokenUrl?: string; accessToken?: string; + + // DB 기반 토큰 모드 + dbTableName?: string; + dbValueColumn?: string; + dbWhereColumn?: string; + dbWhereValue?: string; + dbHeaderName?: string; + dbHeaderTemplate?: string; }; timeout?: number; retry_count?: number; @@ -54,8 +73,9 @@ export interface RestApiTestRequest { id?: number; base_url: string; endpoint?: string; - method?: "GET" | "POST" | "PUT" | "DELETE"; + method?: "GET" | "POST" | "PUT" | "DELETE" | "PATCH"; headers?: Record; + body?: any; // 테스트 요청 바디 추가 auth_type?: AuthType; auth_config?: any; timeout?: number; @@ -76,4 +96,5 @@ export const AUTH_TYPE_OPTIONS = [ { value: "bearer", label: "Bearer Token" }, { value: "basic", label: "Basic Auth" }, { value: "oauth2", label: "OAuth 2.0" }, + { value: "db-token", label: "DB 토큰" }, ]; diff --git a/frontend/components/admin/AuthenticationConfig.tsx b/frontend/components/admin/AuthenticationConfig.tsx index 8bcc438d..da403add 100644 --- a/frontend/components/admin/AuthenticationConfig.tsx +++ b/frontend/components/admin/AuthenticationConfig.tsx @@ -42,6 +42,7 @@ export function AuthenticationConfig({ Bearer Token Basic Auth OAuth 2.0 + DB 토큰 @@ -192,6 +193,94 @@ export function AuthenticationConfig({ )} + {authType === "db-token" && ( +
+

DB 기반 토큰 설정

+ +
+ + updateAuthConfig("dbTableName", e.target.value)} + placeholder="예: auth_tokens" + /> +
+ +
+ + + updateAuthConfig("dbValueColumn", e.target.value) + } + placeholder="예: access_token" + /> +
+ +
+ + + updateAuthConfig("dbWhereColumn", e.target.value) + } + placeholder="예: service_name" + /> +
+ +
+ + + updateAuthConfig("dbWhereValue", e.target.value) + } + placeholder="예: kakao" + /> +
+ +
+ + + updateAuthConfig("dbHeaderName", e.target.value) + } + placeholder="기본값: Authorization" + /> +
+ +
+ + + updateAuthConfig("dbHeaderTemplate", e.target.value) + } + placeholder='기본값: "Bearer {{value}}"' + /> +
+ +

+ company_code는 현재 로그인한 사용자의 회사 코드로 자동 필터링됩니다. +

+
+ )} + {authType === "none" && (
인증이 필요하지 않은 공개 API입니다. diff --git a/frontend/components/admin/RestApiConnectionModal.tsx b/frontend/components/admin/RestApiConnectionModal.tsx index 8e6d502e..8795fa40 100644 --- a/frontend/components/admin/RestApiConnectionModal.tsx +++ b/frontend/components/admin/RestApiConnectionModal.tsx @@ -21,10 +21,13 @@ import { ExternalRestApiConnection, AuthType, RestApiTestResult, + RestApiTestRequest, } from "@/lib/api/externalRestApiConnection"; import { HeadersManager } from "./HeadersManager"; import { AuthenticationConfig } from "./AuthenticationConfig"; import { Badge } from "@/components/ui/badge"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; interface RestApiConnectionModalProps { isOpen: boolean; @@ -42,6 +45,8 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }: const [baseUrl, setBaseUrl] = useState(""); const [endpointPath, setEndpointPath] = useState(""); const [defaultHeaders, setDefaultHeaders] = useState>({}); + const [defaultMethod, setDefaultMethod] = useState("GET"); + const [defaultBody, setDefaultBody] = useState(""); const [authType, setAuthType] = useState("none"); const [authConfig, setAuthConfig] = useState({}); const [timeout, setTimeout] = useState(30000); @@ -52,6 +57,8 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }: // UI 상태 const [showAdvanced, setShowAdvanced] = useState(false); const [testEndpoint, setTestEndpoint] = useState(""); + const [testMethod, setTestMethod] = useState("GET"); + const [testBody, setTestBody] = useState(""); const [testing, setTesting] = useState(false); const [testResult, setTestResult] = useState(null); const [testRequestUrl, setTestRequestUrl] = useState(""); @@ -65,12 +72,19 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }: setBaseUrl(connection.base_url); setEndpointPath(connection.endpoint_path || ""); setDefaultHeaders(connection.default_headers || {}); + setDefaultMethod(connection.default_method || "GET"); + setDefaultBody(connection.default_body || ""); setAuthType(connection.auth_type); setAuthConfig(connection.auth_config || {}); setTimeout(connection.timeout || 30000); setRetryCount(connection.retry_count || 0); setRetryDelay(connection.retry_delay || 1000); setIsActive(connection.is_active === "Y"); + + // 테스트 초기값 설정 + setTestEndpoint(""); + setTestMethod(connection.default_method || "GET"); + setTestBody(connection.default_body || ""); } else { // 초기화 setConnectionName(""); @@ -78,16 +92,22 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }: setBaseUrl(""); setEndpointPath(""); setDefaultHeaders({ "Content-Type": "application/json" }); + setDefaultMethod("GET"); + setDefaultBody(""); setAuthType("none"); setAuthConfig({}); setTimeout(30000); setRetryCount(0); setRetryDelay(1000); setIsActive(true); + + // 테스트 초기값 설정 + setTestEndpoint(""); + setTestMethod("GET"); + setTestBody(""); } setTestResult(null); - setTestEndpoint(""); setTestRequestUrl(""); }, [connection, isOpen]); @@ -111,14 +131,18 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }: setTestRequestUrl(fullUrl); try { - const result = await ExternalRestApiConnectionAPI.testConnection({ + const testRequest: RestApiTestRequest = { base_url: baseUrl, endpoint: testEndpoint || undefined, + method: testMethod as any, headers: defaultHeaders, + body: testBody ? JSON.parse(testBody) : undefined, auth_type: authType, auth_config: authConfig, timeout, - }); + }; + + const result = await ExternalRestApiConnectionAPI.testConnection(testRequest); setTestResult(result); @@ -178,6 +202,20 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }: return; } + // JSON 유효성 검증 + if (defaultBody && defaultMethod !== "GET" && defaultMethod !== "DELETE") { + try { + JSON.parse(defaultBody); + } catch { + toast({ + title: "입력 오류", + description: "기본 Body가 올바른 JSON 형식이 아닙니다.", + variant: "destructive", + }); + return; + } + } + setSaving(true); try { @@ -187,6 +225,8 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }: base_url: baseUrl, endpoint_path: endpointPath || undefined, default_headers: defaultHeaders, + default_method: defaultMethod, + default_body: defaultBody || undefined, auth_type: authType, auth_config: authType === "none" ? undefined : authConfig, timeout, @@ -262,12 +302,28 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }: - setBaseUrl(e.target.value)} - placeholder="https://api.example.com" - /> +
+ +
+ setBaseUrl(e.target.value)} + placeholder="https://api.example.com" + /> +
+

도메인 부분만 입력하세요 (예: https://apihub.kma.go.kr)

@@ -286,6 +342,21 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:

+ {/* 기본 Body (POST, PUT, PATCH일 때만 표시) */} + {(defaultMethod === "POST" || defaultMethod === "PUT" || defaultMethod === "PATCH") && ( +
+ +