diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 2e753b56..87470dd6 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -282,7 +282,7 @@ app.listen(PORT, HOST, async () => { // 배치 스케줄러 초기화 try { - await BatchSchedulerService.initialize(); + await BatchSchedulerService.initializeScheduler(); logger.info(`⏰ 배치 스케줄러가 시작되었습니다.`); } catch (error) { logger.error(`❌ 배치 스케줄러 초기화 실패:`, error); diff --git a/backend-node/src/controllers/DashboardController.ts b/backend-node/src/controllers/DashboardController.ts index 521f5250..76b666f0 100644 --- a/backend-node/src/controllers/DashboardController.ts +++ b/backend-node/src/controllers/DashboardController.ts @@ -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"; /** * 대시보드 컨트롤러 @@ -415,7 +419,7 @@ export class DashboardController { limit: Math.min(parseInt(req.query.limit as string) || 20, 100), search: req.query.search as string, category: req.query.category as string, - createdBy: userId, // 본인이 만든 대시보드만 + // createdBy 제거 - 회사 대시보드 전체 표시 }; const result = await DashboardService.getDashboards( @@ -590,7 +594,14 @@ export class DashboardController { res: Response ): Promise { 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 호출 오류", }); } diff --git a/backend-node/src/controllers/batchManagementController.ts b/backend-node/src/controllers/batchManagementController.ts index 61194485..cc91de80 100644 --- a/backend-node/src/controllers/batchManagementController.ts +++ b/backend-node/src/controllers/batchManagementController.ts @@ -594,7 +594,7 @@ export class BatchManagementController { if (result.success && result.data) { // 스케줄러에 자동 등록 ✅ try { - await BatchSchedulerService.scheduleBatchConfig(result.data); + await BatchSchedulerService.scheduleBatch(result.data); console.log( `✅ 새로운 배치가 스케줄러에 등록되었습니다: ${batchName} (ID: ${result.data.id})` ); diff --git a/backend-node/src/controllers/digitalTwinLayoutController.ts b/backend-node/src/controllers/digitalTwinLayoutController.ts index d7ecbae1..f95ed0e2 100644 --- a/backend-node/src/controllers/digitalTwinLayoutController.ts +++ b/backend-node/src/controllers/digitalTwinLayoutController.ts @@ -22,11 +22,19 @@ export const getLayouts = async ( LEFT JOIN user_info u1 ON l.created_by = u1.user_id LEFT JOIN user_info u2 ON l.updated_by = u2.user_id LEFT JOIN digital_twin_objects o ON l.id = o.layout_id - WHERE l.company_code = $1 `; - const params: any[] = [companyCode]; - let paramIndex = 2; + const params: any[] = []; + let paramIndex = 1; + + // 최고 관리자는 모든 레이아웃 조회 가능 + if (companyCode && companyCode !== '*') { + query += ` WHERE l.company_code = $${paramIndex}`; + params.push(companyCode); + paramIndex++; + } else { + query += ` WHERE 1=1`; + } if (externalDbConnectionId) { query += ` AND l.external_db_connection_id = $${paramIndex}`; @@ -75,14 +83,27 @@ export const getLayoutById = async ( const companyCode = req.user?.companyCode; const { id } = req.params; - // 레이아웃 기본 정보 - const layoutQuery = ` - SELECT l.* - FROM digital_twin_layout l - WHERE l.id = $1 AND l.company_code = $2 - `; + // 레이아웃 기본 정보 - 최고 관리자는 모든 레이아웃 조회 가능 + let layoutQuery: string; + let layoutParams: any[]; - const layoutResult = await pool.query(layoutQuery, [id, companyCode]); + if (companyCode && companyCode !== '*') { + layoutQuery = ` + SELECT l.* + FROM digital_twin_layout l + WHERE l.id = $1 AND l.company_code = $2 + `; + layoutParams = [id, companyCode]; + } else { + layoutQuery = ` + SELECT l.* + FROM digital_twin_layout l + WHERE l.id = $1 + `; + layoutParams = [id]; + } + + const layoutResult = await pool.query(layoutQuery, layoutParams); if (layoutResult.rowCount === 0) { return res.status(404).json({ diff --git a/backend-node/src/services/DashboardService.ts b/backend-node/src/services/DashboardService.ts index 4b13d6b8..0d96b285 100644 --- a/backend-node/src/services/DashboardService.ts +++ b/backend-node/src/services/DashboardService.ts @@ -178,21 +178,24 @@ export class DashboardService { let params: any[] = []; let paramIndex = 1; - // 회사 코드 필터링 (최우선) + // 회사 코드 필터링 - company_code가 일치하면 해당 회사 사용자는 모두 조회 가능 if (companyCode) { - whereConditions.push(`d.company_code = $${paramIndex}`); - params.push(companyCode); - paramIndex++; - } - - // 권한 필터링 - if (userId) { + if (companyCode === '*') { + // 최고 관리자는 모든 대시보드 조회 가능 + } else { + whereConditions.push(`d.company_code = $${paramIndex}`); + params.push(companyCode); + paramIndex++; + } + } else if (userId) { + // 회사 코드 없이 userId만 있는 경우 (본인 생성 또는 공개) whereConditions.push( `(d.created_by = $${paramIndex} OR d.is_public = true)` ); params.push(userId); paramIndex++; } else { + // 비로그인 사용자는 공개 대시보드만 whereConditions.push("d.is_public = true"); } @@ -228,7 +231,7 @@ export class DashboardService { const whereClause = whereConditions.join(" AND "); - // 대시보드 목록 조회 (users 테이블 조인 제거) + // 대시보드 목록 조회 (user_info 조인하여 생성자 이름 포함) const dashboardQuery = ` SELECT d.id, @@ -242,13 +245,16 @@ export class DashboardService { d.tags, d.category, d.view_count, + d.company_code, + u.user_name as created_by_name, COUNT(de.id) as elements_count FROM dashboards d LEFT JOIN dashboard_elements de ON d.id = de.dashboard_id + LEFT JOIN user_info u ON d.created_by = u.user_id WHERE ${whereClause} GROUP BY d.id, d.title, d.description, d.thumbnail_url, d.is_public, d.created_by, d.created_at, d.updated_at, d.tags, d.category, - d.view_count + d.view_count, d.company_code, u.user_name ORDER BY d.updated_at DESC LIMIT $${paramIndex} OFFSET $${paramIndex + 1} `; @@ -277,12 +283,14 @@ export class DashboardService { thumbnailUrl: row.thumbnail_url, isPublic: row.is_public, createdBy: row.created_by, + createdByName: row.created_by_name || row.created_by, createdAt: row.created_at, updatedAt: row.updated_at, tags: JSON.parse(row.tags || "[]"), category: row.category, viewCount: parseInt(row.view_count || "0"), elementsCount: parseInt(row.elements_count || "0"), + companyCode: row.company_code, })), pagination: { page, diff --git a/backend-node/src/services/batchSchedulerService.ts b/backend-node/src/services/batchSchedulerService.ts index a8f755c3..780118fb 100644 --- a/backend-node/src/services/batchSchedulerService.ts +++ b/backend-node/src/services/batchSchedulerService.ts @@ -124,6 +124,14 @@ export class BatchSchedulerService { try { logger.info(`배치 실행 시작: ${config.batch_name} (ID: ${config.id})`); + // 매핑 정보가 없으면 상세 조회로 다시 가져오기 + if (!config.batch_mappings || config.batch_mappings.length === 0) { + const fullConfig = await BatchService.getBatchConfigById(config.id); + if (fullConfig.success && fullConfig.data) { + config = fullConfig.data; + } + } + // 실행 로그 생성 const executionLogResponse = await BatchExecutionLogService.createExecutionLog({ diff --git a/backend-node/src/services/externalRestApiConnectionService.ts b/backend-node/src/services/externalRestApiConnectionService.ts index 36f3a7e2..af37eff1 100644 --- a/backend-node/src/services/externalRestApiConnectionService.ts +++ b/backend-node/src/services/externalRestApiConnectionService.ts @@ -474,6 +474,105 @@ export class ExternalRestApiConnectionService { } } + /** + * 인증 헤더 생성 + */ + static async getAuthHeaders( + authType: AuthType, + authConfig: any, + companyCode?: string + ): Promise> { + const headers: Record = {}; + + 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 = 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 연결 테스트 (테스트 요청 데이터 기반) */ @@ -485,99 +584,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 = 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; diff --git a/frontend/app/(main)/admin/dashboard/DashboardListClient.tsx b/frontend/app/(main)/admin/dashboard/DashboardListClient.tsx index e7680584..613ab16b 100644 --- a/frontend/app/(main)/admin/dashboard/DashboardListClient.tsx +++ b/frontend/app/(main)/admin/dashboard/DashboardListClient.tsx @@ -195,6 +195,7 @@ export default function DashboardListClient() { 제목 설명 + 생성자 생성일 수정일 작업 @@ -209,6 +210,9 @@ export default function DashboardListClient() {
+ +
+
@@ -277,6 +281,7 @@ export default function DashboardListClient() { 제목 설명 + 생성자 생성일 수정일 작업 @@ -296,6 +301,9 @@ export default function DashboardListClient() { {dashboard.description || "-"} + + {dashboard.createdByName || dashboard.createdBy || "-"} + {formatDate(dashboard.createdAt)} @@ -363,6 +371,10 @@ export default function DashboardListClient() { 설명 {dashboard.description || "-"} +
+ 생성자 + {dashboard.createdByName || dashboard.createdBy || "-"} +
생성일 {formatDate(dashboard.createdAt)} diff --git a/frontend/components/admin/dashboard/data-sources/MultiApiConfig.tsx b/frontend/components/admin/dashboard/data-sources/MultiApiConfig.tsx index 5c516491..86da8fe7 100644 --- a/frontend/components/admin/dashboard/data-sources/MultiApiConfig.tsx +++ b/frontend/components/admin/dashboard/data-sources/MultiApiConfig.tsx @@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; import { Plus, Trash2, Loader2, CheckCircle, XCircle } from "lucide-react"; import { ExternalDbConnectionAPI, ExternalApiConnection } from "@/lib/api/externalDbConnection"; import { getApiUrl } from "@/lib/utils/apiUrl"; @@ -20,7 +21,7 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M const [testing, setTesting] = useState(false); const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null); const [apiConnections, setApiConnections] = useState([]); - const [selectedConnectionId, setSelectedConnectionId] = useState(""); + const [selectedConnectionId, setSelectedConnectionId] = useState(dataSource.externalConnectionId || ""); const [availableColumns, setAvailableColumns] = useState([]); // API 테스트 후 발견된 컬럼 목록 const [columnTypes, setColumnTypes] = useState>({}); // 컬럼 타입 정보 const [sampleData, setSampleData] = useState([]); // 샘플 데이터 (최대 3개) @@ -35,6 +36,13 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M loadApiConnections(); }, []); + // dataSource.externalConnectionId가 변경되면 selectedConnectionId 업데이트 + useEffect(() => { + if (dataSource.externalConnectionId) { + setSelectedConnectionId(dataSource.externalConnectionId); + } + }, [dataSource.externalConnectionId]); + // 외부 커넥션 선택 핸들러 const handleConnectionSelect = async (connectionId: string) => { setSelectedConnectionId(connectionId); @@ -58,11 +66,20 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M const updates: Partial = { endpoint: fullEndpoint, + externalConnectionId: connectionId, // 외부 연결 ID 저장 }; const headers: KeyValuePair[] = []; const queryParams: KeyValuePair[] = []; + // 기본 메서드/바디가 있으면 적용 + if (connection.default_method) { + updates.method = connection.default_method as ChartDataSource["method"]; + } + if (connection.default_body) { + updates.body = connection.default_body; + } + // 기본 헤더가 있으면 적용 if (connection.default_headers && Object.keys(connection.default_headers).length > 0) { Object.entries(connection.default_headers).forEach(([key, value]) => { @@ -210,6 +227,11 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M } }); + const bodyPayload = + dataSource.body && dataSource.body.trim().length > 0 + ? dataSource.body + : undefined; + const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), { method: "POST", headers: { "Content-Type": "application/json" }, @@ -219,6 +241,8 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M method: dataSource.method || "GET", headers, queryParams, + body: bodyPayload, + externalConnectionId: dataSource.externalConnectionId, // 외부 연결 ID 전달 }), }); @@ -415,6 +439,58 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M

+ {/* HTTP 메서드 */} +
+ + +
+ + {/* Request Body (POST/PUT/PATCH 일 때만) */} + {(dataSource.method === "POST" || + dataSource.method === "PUT" || + dataSource.method === "PATCH") && ( +
+ +