외부호출 기능(rest API)

This commit is contained in:
kjs 2025-09-26 17:11:18 +09:00
parent 9454e3a81f
commit 11b71b788a
19 changed files with 3177 additions and 243 deletions

View File

@ -35,6 +35,8 @@ import multiConnectionRoutes from "./routes/multiConnectionRoutes";
import dbTypeCategoryRoutes from "./routes/dbTypeCategoryRoutes"; import dbTypeCategoryRoutes from "./routes/dbTypeCategoryRoutes";
import ddlRoutes from "./routes/ddlRoutes"; import ddlRoutes from "./routes/ddlRoutes";
import entityReferenceRoutes from "./routes/entityReferenceRoutes"; import entityReferenceRoutes from "./routes/entityReferenceRoutes";
import externalCallRoutes from "./routes/externalCallRoutes";
import externalCallConfigRoutes from "./routes/externalCallConfigRoutes";
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석 // import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석 // import batchRoutes from "./routes/batchRoutes"; // 임시 주석
// import userRoutes from './routes/userRoutes'; // import userRoutes from './routes/userRoutes';
@ -88,7 +90,7 @@ app.use(
// Rate Limiting (개발 환경에서는 완화) // Rate Limiting (개발 환경에서는 완화)
const limiter = rateLimit({ const limiter = rateLimit({
windowMs: 1 * 60 * 1000, // 1분 windowMs: 1 * 60 * 1000, // 1분
max: config.nodeEnv === "development" ? 10000 : 100, // 개발환경에서는 10000으로 증가, 운영환경에서는 100 max: config.nodeEnv === "development" ? 10000 : 1000, // 개발환경에서는 10000으로 증가, 운영환경에서는 100
message: { message: {
error: "너무 많은 요청이 발생했습니다. 잠시 후 다시 시도해주세요.", error: "너무 많은 요청이 발생했습니다. 잠시 후 다시 시도해주세요.",
}, },
@ -142,6 +144,8 @@ app.use("/api/multi-connection", multiConnectionRoutes);
app.use("/api/db-type-categories", dbTypeCategoryRoutes); app.use("/api/db-type-categories", dbTypeCategoryRoutes);
app.use("/api/ddl", ddlRoutes); app.use("/api/ddl", ddlRoutes);
app.use("/api/entity-reference", entityReferenceRoutes); app.use("/api/entity-reference", entityReferenceRoutes);
app.use("/api/external-calls", externalCallRoutes);
app.use("/api/external-call-configs", externalCallConfigRoutes);
// app.use("/api/collections", collectionRoutes); // 임시 주석 // app.use("/api/collections", collectionRoutes); // 임시 주석
// app.use("/api/batch", batchRoutes); // 임시 주석 // app.use("/api/batch", batchRoutes); // 임시 주석
// app.use('/api/users', userRoutes); // app.use('/api/users', userRoutes);

View File

@ -180,10 +180,57 @@ export class ExternalCallService {
body = this.processTemplate(body, templateData); body = this.processTemplate(body, templateData);
} }
// 기본 헤더 준비
const headers = { ...(settings.headers || {}) };
// 인증 정보 처리
if (settings.authentication) {
switch (settings.authentication.type) {
case "api-key":
if (settings.authentication.apiKey) {
headers["X-API-Key"] = settings.authentication.apiKey;
}
break;
case "basic":
if (
settings.authentication.username &&
settings.authentication.password
) {
const credentials = Buffer.from(
`${settings.authentication.username}:${settings.authentication.password}`
).toString("base64");
headers["Authorization"] = `Basic ${credentials}`;
}
break;
case "bearer":
if (settings.authentication.token) {
headers["Authorization"] =
`Bearer ${settings.authentication.token}`;
}
break;
case "custom":
if (
settings.authentication.headerName &&
settings.authentication.headerValue
) {
headers[settings.authentication.headerName] =
settings.authentication.headerValue;
}
break;
// 'none' 타입은 아무것도 하지 않음
}
}
console.log(`🔐 [ExternalCallService] 인증 처리 완료:`, {
authType: settings.authentication?.type || "none",
hasAuthHeader: !!headers["Authorization"],
headers: Object.keys(headers),
});
return await this.makeHttpRequest({ return await this.makeHttpRequest({
url: settings.url, url: settings.url,
method: settings.method, method: settings.method,
headers: settings.headers || {}, headers: headers,
body: body, body: body,
timeout: settings.timeout || this.DEFAULT_TIMEOUT, timeout: settings.timeout || this.DEFAULT_TIMEOUT,
}); });
@ -213,17 +260,36 @@ export class ExternalCallService {
const controller = new AbortController(); const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), options.timeout); const timeoutId = setTimeout(() => controller.abort(), options.timeout);
const response = await fetch(options.url, { // GET, HEAD 메서드는 body를 가질 수 없음
const method = options.method.toUpperCase();
const requestOptions: RequestInit = {
method: options.method, method: options.method,
headers: options.headers, headers: options.headers,
body: options.body,
signal: controller.signal, signal: controller.signal,
}); };
// GET, HEAD 메서드가 아닌 경우에만 body 추가
if (method !== "GET" && method !== "HEAD" && options.body) {
requestOptions.body = options.body;
}
const response = await fetch(options.url, requestOptions);
clearTimeout(timeoutId); clearTimeout(timeoutId);
const responseText = await response.text(); const responseText = await response.text();
// 디버깅을 위한 로그 추가
console.log(`🔍 [ExternalCallService] HTTP 응답:`, {
url: options.url,
method: options.method,
status: response.status,
statusText: response.statusText,
ok: response.ok,
headers: Object.fromEntries(response.headers.entries()),
responseText: responseText.substring(0, 500), // 처음 500자만 로그
});
return { return {
success: response.ok, success: response.ok,
statusCode: response.status, statusCode: response.status,

View File

@ -53,14 +53,26 @@ export interface DiscordSettings extends ExternalCallConfig {
avatarUrl?: string; avatarUrl?: string;
} }
// 인증 설정 타입
export interface AuthenticationSettings {
type: "none" | "api-key" | "basic" | "bearer" | "custom";
apiKey?: string;
username?: string;
password?: string;
token?: string;
headerName?: string;
headerValue?: string;
}
// 일반 REST API 설정 // 일반 REST API 설정
export interface GenericApiSettings extends ExternalCallConfig { export interface GenericApiSettings extends ExternalCallConfig {
callType: "rest-api"; callType: "rest-api";
apiType: "generic"; apiType: "generic";
url: string; url: string;
method: "GET" | "POST" | "PUT" | "DELETE"; method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "HEAD";
headers?: Record<string, string>; headers?: Record<string, string>;
body?: string; body?: string;
authentication?: AuthenticationSettings;
} }
// 이메일 설정 // 이메일 설정

View File

@ -23,6 +23,7 @@ import {
SimpleExternalCallSettings, SimpleExternalCallSettings,
ConnectionSetupModalProps, ConnectionSetupModalProps,
} from "@/types/connectionTypes"; } from "@/types/connectionTypes";
import { ExternalCallConfig } from "@/types/external-call/ExternalCallTypes";
import { isConditionalConnection } from "@/utils/connectionUtils"; import { isConditionalConnection } from "@/utils/connectionUtils";
import { useConditionManager } from "@/hooks/useConditionManager"; import { useConditionManager } from "@/hooks/useConditionManager";
import { ConditionalSettings } from "./condition/ConditionalSettings"; import { ConditionalSettings } from "./condition/ConditionalSettings";
@ -30,6 +31,7 @@ import { ConnectionTypeSelector } from "./connection/ConnectionTypeSelector";
import { SimpleKeySettings as SimpleKeySettingsComponent } from "./connection/SimpleKeySettings"; import { SimpleKeySettings as SimpleKeySettingsComponent } from "./connection/SimpleKeySettings";
import { DataSaveSettings as DataSaveSettingsComponent } from "./connection/DataSaveSettings"; import { DataSaveSettings as DataSaveSettingsComponent } from "./connection/DataSaveSettings";
import { SimpleExternalCallSettings as ExternalCallSettingsComponent } from "./connection/SimpleExternalCallSettings"; import { SimpleExternalCallSettings as ExternalCallSettingsComponent } from "./connection/SimpleExternalCallSettings";
import ExternalCallPanel from "./external-call/ExternalCallPanel";
import { toast } from "sonner"; import { toast } from "sonner";
export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
@ -60,6 +62,9 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
message: "", message: "",
}); });
// 새로운 외부호출 설정 상태 (분리된 컴포넌트용)
const [externalCallConfig, setExternalCallConfig] = useState<ExternalCallConfig | null>(null);
// 테이블 및 컬럼 선택을 위한 상태들 // 테이블 및 컬럼 선택을 위한 상태들
const [availableTables, setAvailableTables] = useState<TableInfo[]>([]); const [availableTables, setAvailableTables] = useState<TableInfo[]>([]);
const [selectedFromTable, setSelectedFromTable] = useState<string>(""); const [selectedFromTable, setSelectedFromTable] = useState<string>("");
@ -390,7 +395,13 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
} }
break; break;
case "external-call": case "external-call":
// 외부 호출은 plan에 저장 // 새로운 외부호출 설정을 plan에 저장
if (externalCallConfig) {
plan = {
externalCall: externalCallConfig,
};
} else {
// 기존 설정 호환성 유지
plan = { plan = {
externalCall: { externalCall: {
configId: externalCallSettings.configId, configId: externalCallSettings.configId,
@ -398,6 +409,7 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
message: externalCallSettings.message, message: externalCallSettings.message,
}, },
}; };
}
settings = {}; // 외부 호출은 settings에 저장하지 않음 settings = {}; // 외부 호출은 settings에 저장하지 않음
break; break;
} }
@ -507,6 +519,9 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
// 연결 종류별 설정 패널 렌더링 // 연결 종류별 설정 패널 렌더링
const renderConnectionTypeSettings = () => { const renderConnectionTypeSettings = () => {
console.log("🔍 [ConnectionSetupModal] renderConnectionTypeSettings - connectionType:", config.connectionType);
console.log("🔍 [ConnectionSetupModal] externalCallConfig:", externalCallConfig);
switch (config.connectionType) { switch (config.connectionType) {
case "simple-key": case "simple-key":
return ( return (
@ -540,8 +555,13 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
); );
case "external-call": case "external-call":
console.log("🚀 [ConnectionSetupModal] Rendering ExternalCallPanel");
return ( return (
<ExternalCallSettingsComponent settings={externalCallSettings} onSettingsChange={setExternalCallSettings} /> <ExternalCallPanel
relationshipId={connection?.id || `temp-${Date.now()}`}
initialSettings={externalCallConfig}
onSettingsChange={setExternalCallConfig}
/>
); );
default: default:
@ -631,8 +651,12 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
return !hasActions || !allActionsHaveMappings || !allMappingsComplete || !allRequiredConditionsMet; return !hasActions || !allActionsHaveMappings || !allMappingsComplete || !allRequiredConditionsMet;
case "external-call": case "external-call":
// 외부 호출: 설정 ID와 메시지가 있어야 함 // 외부 호출: 새로운 설정이 있으면 API URL 검증, 없으면 기존 설정 검증
if (externalCallConfig) {
return !externalCallConfig.restApiSettings?.apiUrl?.trim();
} else {
return !externalCallSettings.configId || !externalCallSettings.message?.trim(); return !externalCallSettings.configId || !externalCallSettings.message?.trim();
}
default: default:
return false; return false;

View File

@ -47,7 +47,10 @@ export const ConnectionTypeSelector: React.FC<ConnectionTypeSelectorProps> = ({
? "border-orange-500 bg-orange-50" ? "border-orange-500 bg-orange-50"
: "border-gray-200 hover:border-gray-300" : "border-gray-200 hover:border-gray-300"
}`} }`}
onClick={() => onConfigChange({ ...config, connectionType: "external-call" })} onClick={() => {
console.log("🔄 [ConnectionTypeSelector] External call selected");
onConfigChange({ ...config, connectionType: "external-call" });
}}
> >
<Globe className="mx-auto h-6 w-6 text-orange-500" /> <Globe className="mx-auto h-6 w-6 text-orange-500" />
<div className="mt-1 text-xs font-medium"> </div> <div className="mt-1 text-xs font-medium"> </div>

View File

@ -114,6 +114,9 @@ const DataConnectionDesigner: React.FC<DataConnectionDesignerProps> = ({
controlConditions: initialData.controlConditions || prev.controlConditions, controlConditions: initialData.controlConditions || prev.controlConditions,
fieldMappings: initialData.fieldMappings || prev.fieldMappings, fieldMappings: initialData.fieldMappings || prev.fieldMappings,
// 🔧 외부호출 설정 로드
externalCallConfig: initialData.externalCallConfig || prev.externalCallConfig,
// 🔧 액션 그룹 데이터 로드 (기존 호환성 포함) // 🔧 액션 그룹 데이터 로드 (기존 호환성 포함)
actionGroups: actionGroups:
initialData.actionGroups || initialData.actionGroups ||
@ -155,6 +158,7 @@ const DataConnectionDesigner: React.FC<DataConnectionDesignerProps> = ({
const actions: DataConnectionActions = { const actions: DataConnectionActions = {
// 연결 타입 설정 // 연결 타입 설정
setConnectionType: useCallback((type: "data_save" | "external_call") => { setConnectionType: useCallback((type: "data_save" | "external_call") => {
console.log("🔄 [DataConnectionDesigner] setConnectionType 호출됨:", type);
setState((prev) => ({ setState((prev) => ({
...prev, ...prev,
connectionType: type, connectionType: type,
@ -376,6 +380,15 @@ const DataConnectionDesigner: React.FC<DataConnectionDesignerProps> = ({
toast.success("제어 조건이 삭제되었습니다."); toast.success("제어 조건이 삭제되었습니다.");
}, []), }, []),
// 외부호출 설정 업데이트
updateExternalCallConfig: useCallback((config: any) => {
console.log("🔄 외부호출 설정 업데이트:", config);
setState((prev) => ({
...prev,
externalCallConfig: config,
}));
}, []),
// 액션 설정 관리 // 액션 설정 관리
setActionType: useCallback((type: "insert" | "update" | "delete" | "upsert") => { setActionType: useCallback((type: "insert" | "update" | "delete" | "upsert") => {
setState((prev) => ({ setState((prev) => ({
@ -534,6 +547,15 @@ const DataConnectionDesigner: React.FC<DataConnectionDesignerProps> = ({
return; return;
} }
// 외부호출인 경우 API URL만 확인 (테이블 검증 제외)
if (state.connectionType === "external_call") {
if (!state.externalCallConfig?.restApiSettings?.apiUrl) {
toast.error("API URL을 입력해주세요.");
return;
}
// 외부호출은 테이블 정보 검증 건너뛰기
}
// 중복 체크 (수정 모드가 아닌 경우에만) // 중복 체크 (수정 모드가 아닌 경우에만)
if (!diagramId) { if (!diagramId) {
try { try {
@ -558,22 +580,62 @@ const DataConnectionDesigner: React.FC<DataConnectionDesignerProps> = ({
relationshipName: state.relationshipName, relationshipName: state.relationshipName,
description: state.description, description: state.description,
connectionType: state.connectionType, connectionType: state.connectionType,
fromConnection: state.fromConnection, // 외부호출인 경우 테이블 정보는 선택사항
toConnection: state.toConnection, fromConnection: state.connectionType === "external_call" ? null : state.fromConnection,
fromTable: state.fromTable, toConnection: state.connectionType === "external_call" ? null : state.toConnection,
toTable: state.toTable, fromTable: state.connectionType === "external_call" ? null : state.fromTable,
toTable: state.connectionType === "external_call" ? null : state.toTable,
// 🔧 멀티 액션 그룹 데이터 포함 // 🔧 멀티 액션 그룹 데이터 포함
actionGroups: state.actionGroups, actionGroups: state.connectionType === "external_call" ? [] : state.actionGroups,
groupsLogicalOperator: state.groupsLogicalOperator, groupsLogicalOperator: state.groupsLogicalOperator,
// 외부호출 설정 포함
externalCallConfig: state.externalCallConfig,
// 기존 호환성을 위한 필드들 (첫 번째 액션 그룹의 첫 번째 액션에서 추출) // 기존 호환성을 위한 필드들 (첫 번째 액션 그룹의 첫 번째 액션에서 추출)
actionType: state.actionGroups[0]?.actions[0]?.actionType || state.actionType || "insert", actionType:
controlConditions: state.controlConditions, state.connectionType === "external_call"
actionConditions: state.actionGroups[0]?.actions[0]?.conditions || state.actionConditions || [], ? "external_call"
fieldMappings: state.actionGroups[0]?.actions[0]?.fieldMappings || state.fieldMappings || [], : state.actionGroups[0]?.actions[0]?.actionType || state.actionType || "insert",
controlConditions: state.connectionType === "external_call" ? [] : state.controlConditions,
actionConditions:
state.connectionType === "external_call"
? []
: state.actionGroups[0]?.actions[0]?.conditions || state.actionConditions || [],
fieldMappings:
state.connectionType === "external_call"
? []
: state.actionGroups[0]?.actions[0]?.fieldMappings || state.fieldMappings || [],
}; };
console.log("💾 직접 저장 시작:", { saveData, diagramId, isEdit: !!diagramId }); console.log("💾 직접 저장 시작:", { saveData, diagramId, isEdit: !!diagramId });
// 외부호출인 경우 external-call-configs에 설정 저장
if (state.connectionType === "external_call" && state.externalCallConfig) {
try {
const { ExternalCallConfigAPI } = await import("@/lib/api/externalCallConfig");
const configData = {
config_name: state.relationshipName || "외부호출 설정",
call_type: "rest-api",
api_type: "generic",
config_data: state.externalCallConfig.restApiSettings,
description: state.description || "",
company_code: "*", // 기본값
};
const configResult = await ExternalCallConfigAPI.createConfig(configData);
if (!configResult.success) {
throw new Error(configResult.error || "외부호출 설정 저장 실패");
}
console.log("✅ 외부호출 설정 저장 완료:", configResult.data);
} catch (configError) {
console.error("❌ 외부호출 설정 저장 실패:", configError);
// 외부호출 설정 저장 실패해도 관계는 저장하도록 함
toast.error("외부호출 설정 저장에 실패했지만 관계는 저장되었습니다.");
}
}
// 백엔드 API 호출 (수정 모드인 경우 diagramId 전달) // 백엔드 API 호출 (수정 모드인 경우 diagramId 전달)
const result = await saveDataflowRelationship(saveData, diagramId); const result = await saveDataflowRelationship(saveData, diagramId);
@ -641,15 +703,15 @@ const DataConnectionDesigner: React.FC<DataConnectionDesignerProps> = ({
)} )}
{/* 메인 컨텐츠 - 좌우 분할 레이아웃 */} {/* 메인 컨텐츠 - 좌우 분할 레이아웃 */}
<div className="flex h-[calc(100vh-280px)] min-h-[600px] overflow-hidden"> <div className="flex h-[calc(100vh-200px)] min-h-[700px] overflow-hidden">
{/* 좌측 패널 (30%) */} {/* 좌측 패널 (30%) - 항상 표시 */}
<div className="flex w-[30%] flex-col border-r bg-white"> <div className="flex w-[30%] flex-col border-r bg-white">
<LeftPanel state={state} actions={actions} /> <LeftPanel state={state} actions={actions} />
</div> </div>
{/* 우측 패널 (70%) */} {/* 우측 패널 (70%) */}
<div className="flex w-[70%] flex-col bg-gray-50"> <div className="flex w-[70%] flex-col bg-gray-50">
<RightPanel state={state} actions={actions} /> <RightPanel key={state.connectionType} state={state} actions={actions} />
</div> </div>
</div> </div>
</div> </div>

View File

@ -35,7 +35,10 @@ const ConnectionTypeSelector: React.FC<ConnectionTypeSelectorProps> = ({ selecte
<CardContent className="p-4"> <CardContent className="p-4">
<RadioGroup <RadioGroup
value={selectedType} value={selectedType}
onValueChange={(value) => onTypeChange(value as "data_save" | "external_call")} onValueChange={(value) => {
console.log("🔘 [ConnectionTypeSelector] 라디오 버튼 변경:", value);
onTypeChange(value as "data_save" | "external_call");
}}
className="space-y-3" className="space-y-3"
> >
{connectionTypes.map((type) => ( {connectionTypes.map((type) => (

View File

@ -30,6 +30,9 @@ const LeftPanel: React.FC<LeftPanelProps> = ({ state, actions }) => {
<ConnectionTypeSelector selectedType={state.connectionType} onTypeChange={actions.setConnectionType} /> <ConnectionTypeSelector selectedType={state.connectionType} onTypeChange={actions.setConnectionType} />
</div> </div>
{/* 외부호출이 아닐 때만 매핑과 액션 설정 표시 */}
{state.connectionType !== "external_call" && (
<>
<Separator /> <Separator />
{/* 매핑 상세 목록 */} {/* 매핑 상세 목록 */}
@ -78,6 +81,19 @@ const LeftPanel: React.FC<LeftPanelProps> = ({ state, actions }) => {
<h3 className="text-muted-foreground mb-2 text-sm font-medium"> </h3> <h3 className="text-muted-foreground mb-2 text-sm font-medium"> </h3>
<ActionSummaryPanel state={state} /> <ActionSummaryPanel state={state} />
</div> </div>
</>
)}
{/* 외부호출일 때는 간단한 설명만 표시 */}
{state.connectionType === "external_call" && (
<>
<Separator />
<div className="rounded-md bg-blue-50 p-3">
<h3 className="mb-1 text-sm font-medium text-blue-800"> </h3>
<p className="text-xs text-blue-600"> REST API .</p>
</div>
</>
)}
</div> </div>
</ScrollArea> </ScrollArea>
</div> </div>

View File

@ -1,7 +1,9 @@
"use client"; "use client";
import React from "react"; import React, { useEffect } from "react";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Globe } from "lucide-react";
// 타입 import // 타입 import
import { RightPanelProps } from "../types/redesigned"; import { RightPanelProps } from "../types/redesigned";
@ -14,6 +16,7 @@ import FieldMappingStep from "./FieldMappingStep";
import ControlConditionStep from "./ControlConditionStep"; import ControlConditionStep from "./ControlConditionStep";
import ActionConfigStep from "./ActionConfigStep"; import ActionConfigStep from "./ActionConfigStep";
import MultiActionConfigStep from "./MultiActionConfigStep"; import MultiActionConfigStep from "./MultiActionConfigStep";
import ExternalCallPanel from "../../../external-call/ExternalCallPanel";
/** /**
* 🎯 (70% ) * 🎯 (70% )
@ -22,6 +25,12 @@ import MultiActionConfigStep from "./MultiActionConfigStep";
* - * -
*/ */
const RightPanel: React.FC<RightPanelProps> = ({ state, actions }) => { const RightPanel: React.FC<RightPanelProps> = ({ state, actions }) => {
console.log("🔄 [RightPanel] 컴포넌트 렌더링 - connectionType:", state.connectionType);
// connectionType 변경 감지
useEffect(() => {
console.log("🔄 [RightPanel] connectionType 변경됨:", state.connectionType);
}, [state.connectionType]);
// 완료된 단계 계산 // 완료된 단계 계산
const completedSteps: number[] = []; const completedSteps: number[] = [];
@ -52,6 +61,92 @@ const RightPanel: React.FC<RightPanelProps> = ({ state, actions }) => {
} }
const renderCurrentStep = () => { const renderCurrentStep = () => {
try {
// 외부호출인 경우 단계 무시하고 바로 외부호출 설정 화면 표시
console.log("🔍 [RightPanel] renderCurrentStep - connectionType:", state.connectionType);
if (state.connectionType === "external_call") {
console.log("✅ [RightPanel] 외부호출 화면 렌더링");
return (
<div className="flex h-full flex-col">
{/* 헤더 */}
<div className="flex-shrink-0 px-4 py-2">
<div className="flex items-center gap-3 border-b pb-2">
<Globe className="h-5 w-5 text-blue-600" />
<h2 className="text-lg font-semibold"> </h2>
</div>
<p className="text-muted-foreground mt-1 text-sm">
REST API .
</p>
</div>
{/* 관계명 및 설명 입력 */}
<div className="flex-shrink-0 px-4 pb-2">
<div className="space-y-3">
<div>
<label className="text-sm font-medium"> *</label>
<input
type="text"
value={state.relationshipName || ""}
onChange={(e) => actions.setRelationshipName(e.target.value)}
placeholder="외부호출 관계의 이름을 입력하세요"
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div>
<label className="text-sm font-medium"></label>
<textarea
value={state.description || ""}
onChange={(e) => actions.setDescription(e.target.value)}
placeholder="외부호출의 용도나 설명을 입력하세요"
rows={2}
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
</div>
</div>
{/* 외부호출 패널 - 공간 최적화 */}
<div className="flex-1 overflow-hidden px-4">
<div className="h-full max-h-[calc(100vh-400px)] overflow-y-auto">
<ExternalCallPanel
relationshipId={`external-call-${Date.now()}`}
readonly={false}
initialSettings={
state.externalCallConfig || {
callType: "rest-api",
restApiSettings: {
apiUrl: "",
httpMethod: "POST",
headers: { "Content-Type": "application/json" },
bodyTemplate: "",
authentication: { type: "none" },
timeout: 30000,
retryCount: 3,
},
}
}
onSettingsChange={actions.updateExternalCallConfig}
/>
</div>
</div>
{/* 하단 버튼 - 바로 붙여서 고정 */}
<div className="flex-shrink-0 border-t bg-white px-4 py-2">
<div className="flex gap-3">
<Button
onClick={actions.saveMappings}
className="w-full bg-blue-600 hover:bg-blue-700"
disabled={state.isLoading}
>
{state.isLoading ? "저장 중..." : "저장"}
</Button>
</div>
</div>
</div>
);
}
// 데이터 저장인 경우에만 단계별 진행
switch (state.currentStep) { switch (state.currentStep) {
case 1: case 1:
return ( return (
@ -71,6 +166,7 @@ const RightPanel: React.FC<RightPanelProps> = ({ state, actions }) => {
case 2: case 2:
return ( return (
<div className="space-y-4">
<TableStep <TableStep
fromConnection={state.fromConnection} fromConnection={state.fromConnection}
toConnection={state.toConnection} toConnection={state.toConnection}
@ -80,10 +176,11 @@ const RightPanel: React.FC<RightPanelProps> = ({ state, actions }) => {
onNext={() => actions.goToStep(3)} // 3단계(제어 조건)로 onNext={() => actions.goToStep(3)} // 3단계(제어 조건)로
onBack={() => actions.goToStep(1)} onBack={() => actions.goToStep(1)}
/> />
</div>
); );
case 3: case 3:
// 3단계: 제어 조건 // 데이터 저장인 경우 제어 조건 단계
return ( return (
<ControlConditionStep <ControlConditionStep
state={state} state={state}
@ -98,6 +195,11 @@ const RightPanel: React.FC<RightPanelProps> = ({ state, actions }) => {
); );
case 4: case 4:
// 외부호출인 경우 4단계 없음
if (state.connectionType === "external_call") {
return null;
}
// 4단계: 통합된 멀티 액션 설정 (제어 조건 + 액션 설정 + 컬럼 매핑) // 4단계: 통합된 멀티 액션 설정 (제어 조건 + 액션 설정 + 컬럼 매핑)
return ( return (
<MultiActionConfigStep <MultiActionConfigStep
@ -135,21 +237,49 @@ const RightPanel: React.FC<RightPanelProps> = ({ state, actions }) => {
default: default:
return null; return null;
} }
} catch (error) {
console.error("❌ [RightPanel] renderCurrentStep 에러:", error);
return <div>renderCurrentStep : {String(error)}</div>;
}
}; };
try {
console.log("🎯 [RightPanel] return 시작 - connectionType:", state.connectionType);
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
{/* 단계 진행 표시 */} {/* 단계 진행 표시 - 외부호출이 아닐 때만 */}
{state.connectionType !== "external_call" && (
<div className="bg-card/50 border-b p-3"> <div className="bg-card/50 border-b p-3">
<StepProgress currentStep={state.currentStep} completedSteps={completedSteps} onStepClick={actions.goToStep} /> <StepProgress
currentStep={state.currentStep}
completedSteps={completedSteps}
onStepClick={actions.goToStep}
/>
</div> </div>
)}
{/* 현재 단계 컨텐츠 */} {/* 현재 단계 컨텐츠 */}
<div className="min-h-0 flex-1 p-3"> <div className="min-h-0 flex-1 p-3">
<Card className="flex h-full flex-col overflow-hidden">{renderCurrentStep()}</Card> {(() => {
console.log("🎯 [RightPanel] 조건부 렌더링 체크 - connectionType:", state.connectionType);
console.log("🎯 [RightPanel] external_call인가?", state.connectionType === "external_call");
if (state.connectionType === "external_call") {
console.log("🎯 [RightPanel] 외부호출 렌더링 시작");
return renderCurrentStep();
} else {
console.log("🎯 [RightPanel] 데이터 저장 렌더링 시작");
return <Card className="flex h-full flex-col overflow-hidden">{renderCurrentStep()}</Card>;
}
})()}
</div> </div>
</div> </div>
); );
} catch (error) {
console.error("❌ [RightPanel] 렌더링 에러:", error);
return <div> : {String(error)}</div>;
}
}; };
export default RightPanel; export default RightPanel;

View File

@ -111,6 +111,22 @@ export interface DataConnectionState {
actionGroups: ActionGroup[]; actionGroups: ActionGroup[];
groupsLogicalOperator?: "AND" | "OR"; // 그룹 간의 논리 연산자 groupsLogicalOperator?: "AND" | "OR"; // 그룹 간의 논리 연산자
// 외부호출 설정
externalCallConfig?: {
restApiSettings: {
apiUrl: string;
httpMethod: string;
headers: Record<string, string>;
bodyTemplate: string;
authentication: {
type: string;
[key: string]: any;
};
timeout: number;
retryCount: number;
};
};
// 기존 호환성을 위한 필드들 (deprecated) // 기존 호환성을 위한 필드들 (deprecated)
actionType?: "insert" | "update" | "delete" | "upsert"; actionType?: "insert" | "update" | "delete" | "upsert";
actionConditions?: any[]; // 각 액션의 대상 레코드 조건 actionConditions?: any[]; // 각 액션의 대상 레코드 조건
@ -154,6 +170,9 @@ export interface DataConnectionActions {
updateControlCondition: (index: number, condition: any) => void; updateControlCondition: (index: number, condition: any) => void;
deleteControlCondition: (index: number) => void; deleteControlCondition: (index: number) => void;
// 외부호출 설정 관리
updateExternalCallConfig: (config: any) => void;
// 액션 그룹 관리 (멀티 액션) // 액션 그룹 관리 (멀티 액션)
addActionGroup: () => void; addActionGroup: () => void;
updateActionGroup: (groupId: string, updates: Partial<ActionGroup>) => void; updateActionGroup: (groupId: string, updates: Partial<ActionGroup>) => void;

View File

@ -0,0 +1,322 @@
"use client";
import React, { useState, useCallback, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Globe, Settings, TestTube, History, Info } from "lucide-react";
// 타입 import
import {
ExternalCallConfig,
ExternalCallPanelProps,
RestApiSettings as RestApiSettingsType,
ApiTestResult,
} from "@/types/external-call/ExternalCallTypes";
// 하위 컴포넌트 import
import RestApiSettings from "./RestApiSettings";
import ExternalCallTestPanel from "./ExternalCallTestPanel";
/**
* 🌐
*
*
* REST API , ,
*/
const ExternalCallPanel: React.FC<ExternalCallPanelProps> = ({
relationshipId,
onSettingsChange,
initialSettings,
readonly = false,
}) => {
console.log("🌐 [ExternalCallPanel] Component mounted with props:", {
relationshipId,
initialSettings,
readonly,
});
// 상태 관리
const [config, setConfig] = useState<ExternalCallConfig>(
() =>
initialSettings || {
callType: "rest-api",
restApiSettings: {
apiUrl: "",
httpMethod: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
bodyTemplate: `{
"message": "데이터가 업데이트되었습니다",
"data": {{sourceData}},
"timestamp": "{{timestamp}}",
"relationshipId": "{{relationshipId}}"
}`,
authentication: {
type: "none",
},
timeout: 30000, // 30초
retryCount: 3,
},
},
);
const [activeTab, setActiveTab] = useState<string>("settings");
const [lastTestResult, setLastTestResult] = useState<ApiTestResult | null>(null);
const [isConfigValid, setIsConfigValid] = useState<boolean>(false);
// 설정 변경 핸들러
const handleRestApiSettingsChange = useCallback(
(newSettings: RestApiSettingsType) => {
const updatedConfig: ExternalCallConfig = {
...config,
restApiSettings: newSettings,
metadata: {
...config.metadata,
updatedAt: new Date().toISOString(),
version: "1.0",
},
};
setConfig(updatedConfig);
onSettingsChange(updatedConfig);
},
[config, onSettingsChange],
);
// 테스트 결과 핸들러
const handleTestResult = useCallback((result: ApiTestResult) => {
setLastTestResult(result);
// 테스트 탭에 머물러서 응답 정보를 바로 확인할 수 있도록 함
// (이전에는 성공 시 자동으로 history 탭으로 이동했음)
}, []);
// 설정 유효성 검사
const validateConfig = useCallback(() => {
const { restApiSettings } = config;
// HTTP 메서드에 따라 바디 필요 여부 결정
const methodNeedsBody = !["GET", "HEAD", "DELETE"].includes(restApiSettings.httpMethod?.toUpperCase());
const isValid = !!(
restApiSettings.apiUrl &&
restApiSettings.apiUrl.startsWith("http") &&
restApiSettings.httpMethod &&
(methodNeedsBody ? restApiSettings.bodyTemplate : true) // GET/HEAD/DELETE는 바디 불필요
);
setIsConfigValid(isValid);
return isValid;
}, [config]);
// 설정 변경 시 유효성 검사 실행
useEffect(() => {
validateConfig();
}, [validateConfig]);
return (
<div className="flex h-full max-h-full flex-col space-y-2">
{/* 헤더 */}
<Card>
<CardHeader className="pt-3 pb-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Globe className="h-5 w-5 text-blue-500" />
<CardTitle className="text-lg"> </CardTitle>
<Badge variant={isConfigValid ? "default" : "secondary"}>
{isConfigValid ? "설정 완료" : "설정 필요"}
</Badge>
</div>
</div>
<div className="text-muted-foreground text-sm">
API를 .
</div>
</CardHeader>
</Card>
{/* 메인 탭 컨텐츠 */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex w-full flex-1 flex-col overflow-hidden">
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="settings" className="flex items-center gap-2">
<Settings className="h-4 w-4" />
</TabsTrigger>
<TabsTrigger value="test" className="flex items-center gap-2">
<TestTube className="h-4 w-4" />
</TabsTrigger>
<TabsTrigger value="history" className="flex items-center gap-2">
<History className="h-4 w-4" />
</TabsTrigger>
<TabsTrigger value="info" className="flex items-center gap-2">
<Info className="h-4 w-4" />
</TabsTrigger>
</TabsList>
{/* 설정 탭 */}
<TabsContent value="settings" className="flex-1 space-y-2 overflow-y-auto">
<RestApiSettings
settings={config.restApiSettings}
onSettingsChange={handleRestApiSettingsChange}
readonly={readonly}
/>
</TabsContent>
{/* 테스트 탭 */}
<TabsContent value="test" className="flex-1 space-y-4 overflow-y-auto">
{isConfigValid ? (
<ExternalCallTestPanel
settings={config.restApiSettings}
context={{
relationshipId,
diagramId: "test-diagram",
userId: "current-user",
executionId: "test-execution",
sourceData: { test: "data" },
timestamp: new Date().toISOString(),
}}
onTestResult={handleTestResult}
disabled={readonly}
/>
) : (
<Alert>
<Info className="h-4 w-4" />
<AlertDescription>API .</AlertDescription>
</Alert>
)}
</TabsContent>
{/* 이력 탭 */}
<TabsContent value="history" className="flex-1 space-y-4 overflow-y-auto">
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent>
{lastTestResult ? (
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium"> </span>
<Badge variant={lastTestResult.success ? "default" : "destructive"}>
{lastTestResult.success ? "성공" : "실패"}
</Badge>
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground"> :</span>
<span className="ml-2 font-mono">{lastTestResult.statusCode || "N/A"}</span>
</div>
<div>
<span className="text-muted-foreground"> :</span>
<span className="ml-2 font-mono">{lastTestResult.responseTime}ms</span>
</div>
</div>
{lastTestResult.error && (
<Alert variant="destructive">
<AlertDescription className="text-sm">{lastTestResult.error}</AlertDescription>
</Alert>
)}
{lastTestResult.responseData && (
<div>
<span className="text-muted-foreground text-sm"> :</span>
<pre className="bg-muted mt-1 max-h-32 overflow-auto rounded p-2 text-xs">
{JSON.stringify(lastTestResult.responseData, null, 2)}
</pre>
</div>
)}
</div>
) : (
<div className="text-muted-foreground py-8 text-center">
<TestTube className="mx-auto mb-2 h-8 w-8 opacity-50" />
<p> .</p>
<p className="text-sm"> API .</p>
</div>
)}
</CardContent>
</Card>
</TabsContent>
{/* 정보 탭 */}
<TabsContent value="info" className="flex-1 space-y-4 overflow-y-auto">
<Card>
<CardHeader>
<CardTitle className="text-base">릿 </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="text-muted-foreground text-sm"> 릿 :</div>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<code className="bg-muted rounded px-2 py-1">{"{{sourceData}}"}</code>
<span className="text-muted-foreground"> </span>
</div>
<div className="flex justify-between">
<code className="bg-muted rounded px-2 py-1">{"{{timestamp}}"}</code>
<span className="text-muted-foreground"> </span>
</div>
<div className="flex justify-between">
<code className="bg-muted rounded px-2 py-1">{"{{relationshipId}}"}</code>
<span className="text-muted-foreground"> ID</span>
</div>
<div className="flex justify-between">
<code className="bg-muted rounded px-2 py-1">{"{{userId}}"}</code>
<span className="text-muted-foreground"> ID</span>
</div>
<div className="flex justify-between">
<code className="bg-muted rounded px-2 py-1">{"{{executionId}}"}</code>
<span className="text-muted-foreground"> ID</span>
</div>
</div>
<Alert>
<Info className="h-4 w-4" />
<AlertDescription className="text-sm">
릿 . JSON .
(: {"{{sourceData}}"})
</AlertDescription>
</Alert>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground"> ID:</span>
<code className="bg-muted rounded px-2 py-1 text-xs">{relationshipId}</code>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground"> :</span>
<Badge variant="outline">{config.callType.toUpperCase()}</Badge>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground"> :</span>
<Badge variant={isConfigValid ? "default" : "secondary"}>{isConfigValid ? "완료" : "미완료"}</Badge>
</div>
{config.metadata?.updatedAt && (
<div className="flex justify-between">
<span className="text-muted-foreground"> :</span>
<span className="text-xs">{new Date(config.metadata.updatedAt).toLocaleString()}</span>
</div>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
};
export default ExternalCallPanel;

View File

@ -0,0 +1,497 @@
"use client";
import React, { useState, useCallback, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
TestTube,
Play,
CheckCircle,
XCircle,
Clock,
AlertCircle,
Copy,
RefreshCw,
Zap,
Code,
Network,
Timer,
} from "lucide-react";
import { toast } from "sonner";
// 타입 import
import {
ExternalCallTestPanelProps,
ApiTestResult,
ExternalCallContext,
} from "@/types/external-call/ExternalCallTypes";
import { ExternalCallAPI } from "@/lib/api/externalCall";
/**
* 🧪 API
*
* REST API
* 릿 , ,
*/
const ExternalCallTestPanel: React.FC<ExternalCallTestPanelProps> = ({
settings,
context,
onTestResult,
disabled = false,
}) => {
// 상태 관리
const [isLoading, setIsLoading] = useState<boolean>(false);
const [testResult, setTestResult] = useState<ApiTestResult | null>(null);
const [activeTab, setActiveTab] = useState<string>("request");
const [processedTemplate, setProcessedTemplate] = useState<string>("");
const [testContext, setTestContext] = useState<ExternalCallContext>(() => ({
relationshipId: context?.relationshipId || "test-relationship",
diagramId: context?.diagramId || "test-diagram",
userId: context?.userId || "test-user",
executionId: context?.executionId || `test-${Date.now()}`,
sourceData: context?.sourceData || {
id: 1,
name: "테스트 데이터",
value: 100,
status: "active",
},
targetData: context?.targetData,
timestamp: context?.timestamp || new Date().toISOString(),
metadata: context?.metadata,
}));
// 템플릿 변수 치환 함수
const processTemplate = useCallback((template: string, context: ExternalCallContext): string => {
let processed = template;
// 각 템플릿 변수를 실제 값으로 치환
const replacements = {
"{{sourceData}}": JSON.stringify(context.sourceData, null, 2),
"{{targetData}}": context.targetData ? JSON.stringify(context.targetData, null, 2) : "null",
"{{timestamp}}": context.timestamp,
"{{relationshipId}}": context.relationshipId,
"{{diagramId}}": context.diagramId,
"{{userId}}": context.userId,
"{{executionId}}": context.executionId,
};
Object.entries(replacements).forEach(([variable, value]) => {
processed = processed.replace(new RegExp(variable.replace(/[{}]/g, "\\$&"), "g"), value);
});
return processed;
}, []);
// 템플릿 처리 (설정이나 컨텍스트 변경 시)
useEffect(() => {
if (settings.bodyTemplate) {
const processed = processTemplate(settings.bodyTemplate, testContext);
setProcessedTemplate(processed);
}
}, [settings.bodyTemplate, testContext, processTemplate]);
// API 테스트 실행
const handleRunTest = useCallback(async () => {
if (!settings.apiUrl) {
toast.error("API URL을 입력해주세요.");
return;
}
setIsLoading(true);
setTestResult(null);
try {
// 테스트 요청 데이터 구성 (백엔드 형식에 맞춤)
const testRequest = {
settings: {
callType: "rest-api" as const,
apiType: "generic" as const,
url: settings.apiUrl,
method: settings.httpMethod,
headers: settings.headers,
body: processedTemplate,
authentication: settings.authentication, // 인증 정보 추가
timeout: settings.timeout,
retryCount: settings.retryCount,
},
templateData: testContext,
};
// API 호출
const response = await ExternalCallAPI.testExternalCall(testRequest);
if (response.success && response.result) {
// 백엔드 응답을 ApiTestResult 형태로 변환
const apiTestResult: ApiTestResult = {
success: response.result.success,
statusCode: response.result.statusCode,
responseTime: response.result.executionTime || 0,
response: response.result.response,
error: response.result.error,
timestamp: new Date().toISOString(),
};
setTestResult(apiTestResult);
onTestResult(apiTestResult);
if (apiTestResult.success) {
toast.success("API 테스트가 성공했습니다!");
setActiveTab("response");
} else {
toast.error("API 호출이 실패했습니다.");
setActiveTab("response");
}
} else {
const errorResult: ApiTestResult = {
success: false,
responseTime: 0,
error: response.error || "알 수 없는 오류가 발생했습니다.",
timestamp: new Date().toISOString(),
};
setTestResult(errorResult);
onTestResult(errorResult);
toast.error(response.error || "테스트 실행 중 오류가 발생했습니다.");
}
} catch (error) {
const errorResult: ApiTestResult = {
success: false,
responseTime: 0,
error: error instanceof Error ? error.message : "네트워크 오류가 발생했습니다.",
timestamp: new Date().toISOString(),
};
setTestResult(errorResult);
onTestResult(errorResult);
toast.error("테스트 실행 중 오류가 발생했습니다.");
} finally {
setIsLoading(false);
}
}, [settings, processedTemplate, testContext, onTestResult]);
// 테스트 데이터 복사
const handleCopyToClipboard = useCallback(async (text: string) => {
try {
await navigator.clipboard.writeText(text);
toast.success("클립보드에 복사되었습니다.");
} catch (error) {
toast.error("복사에 실패했습니다.");
}
}, []);
// 테스트 컨텍스트 리셋
const handleResetContext = useCallback(() => {
setTestContext({
relationshipId: "test-relationship",
diagramId: "test-diagram",
userId: "test-user",
executionId: `test-${Date.now()}`,
sourceData: {
id: 1,
name: "테스트 데이터",
value: 100,
status: "active",
},
timestamp: new Date().toISOString(),
});
}, []);
return (
<div className="space-y-4">
{/* 테스트 실행 헤더 */}
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<TestTube className="h-5 w-5 text-blue-500" />
<CardTitle className="text-lg">API </CardTitle>
{testResult && (
<Badge variant={testResult.success ? "default" : "destructive"}>
{testResult.success ? "성공" : "실패"}
</Badge>
)}
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={handleResetContext} disabled={disabled || isLoading}>
<RefreshCw className="mr-1 h-4 w-4" />
</Button>
<Button
onClick={handleRunTest}
disabled={disabled || isLoading || !settings.apiUrl}
className="min-w-[100px]"
>
{isLoading ? (
<>
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
<>
<Play className="mr-2 h-4 w-4" />
</>
)}
</Button>
</div>
</div>
</CardHeader>
</Card>
{/* 테스트 결과 요약 */}
{testResult && (
<Card>
<CardContent className="pt-6">
<div className="grid grid-cols-4 gap-4 text-center">
<div className="space-y-1">
<div className="flex items-center justify-center gap-1">
{testResult.success ? (
<CheckCircle className="h-4 w-4 text-green-500" />
) : (
<XCircle className="h-4 w-4 text-red-500" />
)}
<span className="text-sm font-medium"></span>
</div>
<div className="text-muted-foreground text-xs">{testResult.success ? "성공" : "실패"}</div>
</div>
<div className="space-y-1">
<div className="flex items-center justify-center gap-1">
<Network className="h-4 w-4 text-blue-500" />
<span className="text-sm font-medium"> </span>
</div>
<div className="text-muted-foreground text-xs">{testResult.statusCode || "N/A"}</div>
</div>
<div className="space-y-1">
<div className="flex items-center justify-center gap-1">
<Timer className="h-4 w-4 text-orange-500" />
<span className="text-sm font-medium"> </span>
</div>
<div className="text-muted-foreground text-xs">{testResult.responseTime}ms</div>
</div>
<div className="space-y-1">
<div className="flex items-center justify-center gap-1">
<Clock className="h-4 w-4 text-purple-500" />
<span className="text-sm font-medium"> </span>
</div>
<div className="text-muted-foreground text-xs">
{new Date(testResult.timestamp).toLocaleTimeString()}
</div>
</div>
</div>
</CardContent>
</Card>
)}
{/* 상세 정보 탭 */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="request" className="flex items-center gap-2">
<Zap className="h-4 w-4" />
</TabsTrigger>
<TabsTrigger value="response" className="flex items-center gap-2">
<Code className="h-4 w-4" />
</TabsTrigger>
<TabsTrigger value="context" className="flex items-center gap-2">
<AlertCircle className="h-4 w-4" />
</TabsTrigger>
</TabsList>
{/* 요청 정보 탭 */}
<TabsContent value="request" className="space-y-4">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-base"> </CardTitle>
<Button
variant="outline"
size="sm"
onClick={() =>
handleCopyToClipboard(
JSON.stringify(
{
url: settings.apiUrl,
method: settings.httpMethod,
headers: settings.headers,
body: processedTemplate,
},
null,
2,
),
)
}
>
<Copy className="mr-1 h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* URL과 메서드 */}
<div className="grid grid-cols-4 gap-4">
<div className="col-span-1">
<Label className="text-muted-foreground text-xs">HTTP </Label>
<Badge variant="outline" className="mt-1">
{settings.httpMethod}
</Badge>
</div>
<div className="col-span-3">
<Label className="text-muted-foreground text-xs">URL</Label>
<div className="bg-muted mt-1 rounded p-2 font-mono text-sm break-all">{settings.apiUrl}</div>
</div>
</div>
{/* 헤더 */}
<div>
<Label className="text-muted-foreground text-xs"></Label>
<ScrollArea className="mt-1 h-32 w-full rounded border">
<div className="p-3">
<pre className="text-xs">{JSON.stringify(settings.headers, null, 2)}</pre>
</div>
</ScrollArea>
</div>
{/* 요청 바디 (POST/PUT/PATCH인 경우) */}
{["POST", "PUT", "PATCH"].includes(settings.httpMethod) && (
<div>
<Label className="text-muted-foreground text-xs"> (릿 )</Label>
<ScrollArea className="mt-1 h-40 w-full rounded border">
<div className="p-3">
<pre className="text-xs whitespace-pre-wrap">{processedTemplate}</pre>
</div>
</ScrollArea>
</div>
)}
</CardContent>
</Card>
</TabsContent>
{/* 응답 정보 탭 */}
<TabsContent value="response" className="space-y-4">
{testResult ? (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-base"> </CardTitle>
<Button
variant="outline"
size="sm"
onClick={() => handleCopyToClipboard(JSON.stringify(testResult, null, 2))}
>
<Copy className="mr-1 h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
{testResult.success ? (
<>
{/* 상태 코드 */}
{testResult.statusCode && (
<div>
<Label className="text-muted-foreground text-xs"> </Label>
<div className="mt-1 rounded border bg-green-50 p-2">
<span className="font-mono text-sm text-green-700">{testResult.statusCode}</span>
</div>
</div>
)}
{/* 응답 시간 */}
{testResult.responseTime !== undefined && (
<div>
<Label className="text-muted-foreground text-xs"> </Label>
<div className="mt-1 rounded border bg-blue-50 p-2">
<span className="font-mono text-sm text-blue-700">{testResult.responseTime}ms</span>
</div>
</div>
)}
{/* 응답 데이터 */}
{testResult.response && (
<div>
<Label className="text-muted-foreground text-xs"> </Label>
<ScrollArea className="mt-1 h-40 w-full rounded border">
<div className="p-3">
<pre className="text-xs whitespace-pre-wrap">{testResult.response}</pre>
</div>
</ScrollArea>
</div>
)}
</>
) : (
<Alert variant="destructive">
<XCircle className="h-4 w-4" />
<AlertDescription>
<div className="space-y-2">
<div className="font-medium"> </div>
<div className="text-sm">{testResult.error}</div>
{testResult.statusCode && <div className="text-sm"> : {testResult.statusCode}</div>}
</div>
</AlertDescription>
</Alert>
)}
</CardContent>
</Card>
) : (
<Card>
<CardContent className="pt-6">
<div className="text-muted-foreground py-8 text-center">
<TestTube className="mx-auto mb-2 h-8 w-8 opacity-50" />
<p> .</p>
</div>
</CardContent>
</Card>
)}
</TabsContent>
{/* 테스트 데이터 탭 */}
<TabsContent value="context" className="space-y-4">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-base"> </CardTitle>
<Button
variant="outline"
size="sm"
onClick={() => handleCopyToClipboard(JSON.stringify(testContext, null, 2))}
>
<Copy className="mr-1 h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="text-muted-foreground text-sm">릿 .</div>
<ScrollArea className="h-60 w-full rounded border">
<div className="p-3">
<pre className="text-xs whitespace-pre-wrap">{JSON.stringify(testContext, null, 2)}</pre>
</div>
</ScrollArea>
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription className="text-sm">
. .
</AlertDescription>
</Alert>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
};
export default ExternalCallTestPanel;

View File

@ -0,0 +1,659 @@
"use client";
import React, { useState, useCallback, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import {
Globe,
Key,
Clock,
RefreshCw,
ChevronDown,
ChevronRight,
Plus,
Trash2,
Copy,
Eye,
EyeOff,
AlertCircle,
CheckCircle,
} from "lucide-react";
// 타입 import
import { RestApiSettings as RestApiSettingsType, RestApiSettingsProps } from "@/types/external-call/ExternalCallTypes";
import {
HttpMethod,
AuthenticationType,
COMMON_HEADER_PRESETS,
JSON_BODY_TEMPLATES,
DEFAULT_RETRY_POLICY,
DEFAULT_TIMEOUT_CONFIG,
} from "@/types/external-call/RestApiTypes";
/**
* 🔧 REST API
*
* URL, HTTP , , , 릿
* REST API
*/
const RestApiSettings: React.FC<RestApiSettingsProps> = ({ settings, onSettingsChange, readonly = false }) => {
// 상태 관리
const [activeTab, setActiveTab] = useState<string>("basic");
const [showPassword, setShowPassword] = useState<boolean>(false);
const [isAdvancedOpen, setIsAdvancedOpen] = useState<boolean>(false);
const [validationErrors, setValidationErrors] = useState<string[]>([]);
const [newHeaderKey, setNewHeaderKey] = useState<string>("");
const [newHeaderValue, setNewHeaderValue] = useState<string>("");
// URL 변경 핸들러
const handleUrlChange = useCallback(
(url: string) => {
onSettingsChange({
...settings,
apiUrl: url,
});
},
[settings, onSettingsChange],
);
// HTTP 메서드 변경 핸들러
const handleMethodChange = useCallback(
(method: HttpMethod) => {
onSettingsChange({
...settings,
httpMethod: method,
});
},
[settings, onSettingsChange],
);
// 헤더 추가 핸들러
const handleAddHeader = useCallback(() => {
if (newHeaderKey && newHeaderValue) {
onSettingsChange({
...settings,
headers: {
...settings.headers,
[newHeaderKey]: newHeaderValue,
},
});
setNewHeaderKey("");
setNewHeaderValue("");
}
}, [settings, onSettingsChange, newHeaderKey, newHeaderValue]);
// 헤더 삭제 핸들러
const handleRemoveHeader = useCallback(
(key: string) => {
const newHeaders = { ...settings.headers };
delete newHeaders[key];
onSettingsChange({
...settings,
headers: newHeaders,
});
},
[settings, onSettingsChange],
);
// 헤더 프리셋 적용 핸들러
const handleApplyHeaderPreset = useCallback(
(presetName: string) => {
const preset = COMMON_HEADER_PRESETS.find((p) => p.name === presetName);
if (preset) {
onSettingsChange({
...settings,
headers: {
...settings.headers,
...preset.headers,
},
});
}
},
[settings, onSettingsChange],
);
// 바디 템플릿 변경 핸들러
const handleBodyTemplateChange = useCallback(
(template: string) => {
onSettingsChange({
...settings,
bodyTemplate: template,
});
},
[settings, onSettingsChange],
);
// 바디 템플릿 프리셋 적용 핸들러
const handleApplyBodyPreset = useCallback(
(presetKey: string) => {
const preset = JSON_BODY_TEMPLATES[presetKey as keyof typeof JSON_BODY_TEMPLATES];
if (preset) {
onSettingsChange({
...settings,
bodyTemplate: preset.template,
});
}
},
[settings, onSettingsChange],
);
// 인증 설정 변경 핸들러
const handleAuthChange = useCallback(
(auth: Partial<AuthenticationType>) => {
onSettingsChange({
...settings,
authentication: {
...settings.authentication,
...auth,
} as AuthenticationType,
});
},
[settings, onSettingsChange],
);
// 타임아웃 변경 핸들러 (초 단위를 밀리초로 변환)
const handleTimeoutChange = useCallback(
(timeoutInSeconds: number) => {
onSettingsChange({
...settings,
timeout: timeoutInSeconds * 1000, // 초를 밀리초로 변환
});
},
[settings, onSettingsChange],
);
// 재시도 횟수 변경 핸들러
const handleRetryCountChange = useCallback(
(retryCount: number) => {
onSettingsChange({
...settings,
retryCount,
});
},
[settings, onSettingsChange],
);
// 설정 유효성 검사
const validateSettings = useCallback(() => {
const errors: string[] = [];
// URL 검증
if (!settings.apiUrl) {
errors.push("API URL은 필수입니다.");
} else if (!settings.apiUrl.startsWith("http")) {
errors.push("API URL은 http:// 또는 https://로 시작해야 합니다.");
}
// 바디 템플릿 JSON 검증 (POST/PUT/PATCH 메서드인 경우)
if (["POST", "PUT", "PATCH"].includes(settings.httpMethod) && settings.bodyTemplate) {
try {
// 템플릿 변수를 임시 값으로 치환하여 JSON 유효성 검사
const testTemplate = settings.bodyTemplate.replace(/\{\{[^}]+\}\}/g, '"test_value"');
JSON.parse(testTemplate);
} catch {
errors.push("요청 바디 템플릿이 유효한 JSON 형식이 아닙니다.");
}
}
// 인증 설정 검증
if (settings.authentication?.type === "bearer" && !settings.authentication.token) {
errors.push("Bearer 토큰이 필요합니다.");
}
if (
settings.authentication?.type === "basic" &&
(!settings.authentication.username || !settings.authentication.password)
) {
errors.push("Basic 인증에는 사용자명과 비밀번호가 필요합니다.");
}
if (settings.authentication?.type === "api-key" && !settings.authentication.apiKey) {
errors.push("API 키가 필요합니다.");
}
setValidationErrors(errors);
return errors.length === 0;
}, [settings]);
// 설정 변경 시 유효성 검사 실행
useEffect(() => {
validateSettings();
}, [validateSettings]);
return (
<div className="space-y-4">
{/* 유효성 검사 오류 표시 */}
{validationErrors.length > 0 && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
<div className="space-y-1">
{validationErrors.map((error, index) => (
<div key={index} className="text-sm">
{error}
</div>
))}
</div>
</AlertDescription>
</Alert>
)}
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="basic"> </TabsTrigger>
<TabsTrigger value="headers"></TabsTrigger>
<TabsTrigger value="body"> </TabsTrigger>
<TabsTrigger value="auth"></TabsTrigger>
</TabsList>
{/* 기본 설정 탭 */}
<TabsContent value="basic" className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Globe className="h-4 w-4" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* API URL */}
<div className="space-y-2">
<Label htmlFor="apiUrl">API URL *</Label>
<Input
id="apiUrl"
type="url"
placeholder="https://api.example.com/webhook"
value={settings.apiUrl}
onChange={(e) => handleUrlChange(e.target.value)}
disabled={readonly}
className={validationErrors.some((e) => e.includes("URL")) ? "border-red-500" : ""}
/>
<div className="text-muted-foreground text-xs"> API의 URL을 .</div>
</div>
{/* HTTP 메서드 */}
<div className="space-y-2">
<Label htmlFor="httpMethod">HTTP </Label>
<Select value={settings.httpMethod} onValueChange={handleMethodChange} disabled={readonly}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="GET">GET</SelectItem>
<SelectItem value="POST">POST</SelectItem>
<SelectItem value="PUT">PUT</SelectItem>
<SelectItem value="DELETE">DELETE</SelectItem>
<SelectItem value="PATCH">PATCH</SelectItem>
</SelectContent>
</Select>
</div>
{/* 고급 설정 (접을 수 있는 섹션) */}
<Collapsible open={isAdvancedOpen} onOpenChange={setIsAdvancedOpen}>
<CollapsibleTrigger asChild>
<Button variant="ghost" className="h-auto w-full justify-between p-0">
<span className="flex items-center gap-2">
<Clock className="h-4 w-4" />
</span>
{isAdvancedOpen ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="mt-4 space-y-4">
<div className="grid grid-cols-2 gap-4">
{/* 타임아웃 */}
<div className="space-y-2">
<Label htmlFor="timeout"> ()</Label>
<Input
id="timeout"
type="number"
min="1"
max="300"
value={Math.round((settings.timeout || DEFAULT_TIMEOUT_CONFIG.request) / 1000)}
onChange={(e) => handleTimeoutChange(parseInt(e.target.value))}
disabled={readonly}
/>
</div>
{/* 재시도 횟수 */}
<div className="space-y-2">
<Label htmlFor="retryCount"> </Label>
<Input
id="retryCount"
type="number"
min="0"
max="10"
value={settings.retryCount || DEFAULT_RETRY_POLICY.maxRetries}
onChange={(e) => handleRetryCountChange(parseInt(e.target.value))}
disabled={readonly}
/>
</div>
</div>
</CollapsibleContent>
</Collapsible>
</CardContent>
</Card>
</TabsContent>
{/* 헤더 탭 */}
<TabsContent value="headers" className="space-y-4">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-base">HTTP </CardTitle>
<div className="flex gap-2">
{COMMON_HEADER_PRESETS.map((preset) => (
<Button
key={preset.name}
variant="outline"
size="sm"
onClick={() => handleApplyHeaderPreset(preset.name)}
disabled={readonly}
className="text-xs"
>
{preset.name}
</Button>
))}
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* 기존 헤더 목록 */}
<div className="space-y-2">
{Object.entries(settings.headers).map(([key, value]) => (
<div key={key} className="bg-muted flex items-center gap-2 rounded p-2">
<div className="grid flex-1 grid-cols-2 gap-2">
<Input value={key} disabled className="bg-background" />
<Input value={value} disabled className="bg-background" />
</div>
{!readonly && (
<Button
variant="ghost"
size="sm"
onClick={() => handleRemoveHeader(key)}
className="text-red-500 hover:text-red-700"
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
))}
</div>
{/* 새 헤더 추가 */}
{!readonly && (
<div className="space-y-2">
<Label> </Label>
<div className="flex gap-2">
<Input
placeholder="헤더명 (예: X-API-Key)"
value={newHeaderKey}
onChange={(e) => setNewHeaderKey(e.target.value)}
/>
<Input
placeholder="헤더값"
value={newHeaderValue}
onChange={(e) => setNewHeaderValue(e.target.value)}
/>
<Button onClick={handleAddHeader} disabled={!newHeaderKey || !newHeaderValue}>
<Plus className="h-4 w-4" />
</Button>
</div>
</div>
)}
</CardContent>
</Card>
</TabsContent>
{/* 요청 바디 탭 */}
<TabsContent value="body" className="space-y-4">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-base"> 릿</CardTitle>
<div className="flex gap-2">
{Object.entries(JSON_BODY_TEMPLATES).map(([key, template]) => (
<Button
key={key}
variant="outline"
size="sm"
onClick={() => handleApplyBodyPreset(key)}
disabled={readonly}
className="text-xs"
>
{template.name}
</Button>
))}
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
{["POST", "PUT", "PATCH"].includes(settings.httpMethod) ? (
<>
<Textarea
placeholder="JSON 템플릿을 입력하세요..."
value={settings.bodyTemplate}
onChange={(e) => handleBodyTemplateChange(e.target.value)}
disabled={readonly}
className="min-h-[200px] font-mono text-sm"
/>
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription className="text-sm">
릿 : {"{{sourceData}}"}, {"{{timestamp}}"}, {"{{relationshipId}}"}
</AlertDescription>
</Alert>
</>
) : (
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>{settings.httpMethod} .</AlertDescription>
</Alert>
)}
</CardContent>
</Card>
</TabsContent>
{/* 인증 탭 */}
<TabsContent value="auth" className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Key className="h-4 w-4" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* 인증 타입 선택 */}
<div className="space-y-2">
<Label> </Label>
<Select
value={settings.authentication?.type || "none"}
onValueChange={(type) => handleAuthChange({ type: type as AuthenticationType["type"] })}
disabled={readonly}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"> </SelectItem>
<SelectItem value="bearer">Bearer Token</SelectItem>
<SelectItem value="basic">Basic Authentication</SelectItem>
<SelectItem value="api-key">API Key</SelectItem>
</SelectContent>
</Select>
</div>
{/* 인증 타입별 설정 */}
{settings.authentication?.type === "bearer" && (
<div className="space-y-2">
<Label htmlFor="bearerToken">Bearer Token *</Label>
<div className="relative">
<Input
id="bearerToken"
type={showPassword ? "text" : "password"}
placeholder="토큰을 입력하세요"
value={settings.authentication.token || ""}
onChange={(e) => handleAuthChange({ token: e.target.value })}
disabled={readonly}
className="pr-10"
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute top-0 right-0 h-full px-3"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
</div>
</div>
)}
{settings.authentication?.type === "basic" && (
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="username"> *</Label>
<Input
id="username"
placeholder="사용자명"
value={settings.authentication.username || ""}
onChange={(e) => handleAuthChange({ username: e.target.value })}
disabled={readonly}
/>
</div>
<div className="space-y-2">
<Label htmlFor="password"> *</Label>
<div className="relative">
<Input
id="password"
type={showPassword ? "text" : "password"}
placeholder="비밀번호"
value={settings.authentication.password || ""}
onChange={(e) => handleAuthChange({ password: e.target.value })}
disabled={readonly}
className="pr-10"
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute top-0 right-0 h-full px-3"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
</div>
</div>
</div>
)}
{settings.authentication?.type === "api-key" && (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="apiKey">API Key *</Label>
<div className="relative">
<Input
id="apiKey"
type={showPassword ? "text" : "password"}
placeholder="API 키를 입력하세요"
value={settings.authentication.apiKey || ""}
onChange={(e) => handleAuthChange({ apiKey: e.target.value })}
disabled={readonly}
className="pr-10"
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute top-0 right-0 h-full px-3"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label></Label>
<Select
value={settings.authentication.apiKeyLocation || "header"}
onValueChange={(location) =>
handleAuthChange({
apiKeyLocation: location as "header" | "query",
})
}
disabled={readonly}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="header">HTTP </SelectItem>
<SelectItem value="query"> </SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="keyName"> </Label>
<Input
id="keyName"
placeholder={settings.authentication.apiKeyLocation === "query" ? "api_key" : "X-API-Key"}
value={settings.authentication.apiKeyHeader || settings.authentication.apiKeyQueryParam || ""}
onChange={(e) => {
if (settings.authentication?.apiKeyLocation === "query") {
handleAuthChange({ apiKeyQueryParam: e.target.value });
} else {
handleAuthChange({ apiKeyHeader: e.target.value });
}
}}
disabled={readonly}
/>
</div>
</div>
</div>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/* 설정 상태 표시 */}
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{validationErrors.length === 0 ? (
<>
<CheckCircle className="h-4 w-4 text-green-500" />
<span className="text-sm text-green-600"> </span>
</>
) : (
<>
<AlertCircle className="h-4 w-4 text-orange-500" />
<span className="text-sm text-orange-600">{validationErrors.length} </span>
</>
)}
</div>
<Badge variant={validationErrors.length === 0 ? "default" : "secondary"}>
{validationErrors.length === 0 ? "완료" : "미완료"}
</Badge>
</div>
</CardContent>
</Card>
</div>
);
};
export default RestApiSettings;

View File

@ -63,12 +63,17 @@ export const loadDataflowRelationship = async (diagramId: number) => {
// 기존 구조와 redesigned 구조 모두 지원 // 기존 구조와 redesigned 구조 모두 지원
let relationshipsData; let relationshipsData;
// Case 1: Redesigned UI 구조 (단일 관계 객체) // Case 1: 현재 구조 - relationships 자체가 관계 데이터
if (diagram.relationships.relationships && !Array.isArray(diagram.relationships.relationships)) { if (diagram.relationships && diagram.relationships.connectionType) {
relationshipsData = diagram.relationships;
console.log("✅ 현재 구조 감지 (relationships 직접):", relationshipsData);
}
// Case 2: Redesigned UI 구조 (단일 관계 객체) - 중첩된 relationships
else if (diagram.relationships.relationships && !Array.isArray(diagram.relationships.relationships)) {
relationshipsData = diagram.relationships.relationships; relationshipsData = diagram.relationships.relationships;
console.log("✅ Redesigned 구조 감지:", relationshipsData); console.log("✅ Redesigned 구조 감지:", relationshipsData);
} }
// Case 2: 기존 구조 (관계 배열) - 첫 번째 관계만 로드 // Case 3: 기존 구조 (관계 배열) - 첫 번째 관계만 로드
else if (diagram.relationships.relationships && Array.isArray(diagram.relationships.relationships)) { else if (diagram.relationships.relationships && Array.isArray(diagram.relationships.relationships)) {
const firstRelation = diagram.relationships.relationships[0]; const firstRelation = diagram.relationships.relationships[0];
if (!firstRelation) { if (!firstRelation) {
@ -151,6 +156,8 @@ export const loadDataflowRelationship = async (diagramId: number) => {
fieldMappings: relationshipsData.fieldMappings || [], fieldMappings: relationshipsData.fieldMappings || [],
// 🔧 멀티 액션 그룹 데이터 포함 // 🔧 멀티 액션 그룹 데이터 포함
actionGroups: relationshipsData.actionGroups, actionGroups: relationshipsData.actionGroups,
// 🔧 외부호출 설정 데이터 포함
externalCallConfig: relationshipsData.externalCallConfig,
}; };
console.log("✨ 변환된 로드 데이터:", loadedData); console.log("✨ 변환된 로드 데이터:", loadedData);
@ -194,6 +201,8 @@ export const saveDataflowRelationship = async (data: any, diagramId?: number) =>
description: data.description, description: data.description,
// 🔧 멀티 액션 그룹 데이터 추가 // 🔧 멀티 액션 그룹 데이터 추가
actionGroups: data.actionGroups, actionGroups: data.actionGroups,
// 🔧 외부호출 설정 데이터 추가
externalCallConfig: data.externalCallConfig,
}, },
category: { category: {
type: "data-connection", type: "data-connection",

View File

@ -1,150 +1,176 @@
import { apiClient, ApiResponse } from "./client"; /**
* API
*/
import { apiClient } from "./client";
// 외부호출 설정 관련 상수들
export const CALL_TYPE_OPTIONS = [
{ value: "rest-api", label: "REST API" },
{ value: "webhook", label: "Webhook" },
{ value: "email", label: "이메일" },
] as const;
export const API_TYPE_OPTIONS = [
{ value: "generic", label: "일반 API" },
{ value: "slack", label: "Slack" },
{ value: "discord", label: "Discord" },
{ value: "teams", label: "Microsoft Teams" },
] as const;
export const ACTIVE_STATUS_OPTIONS = [
{ value: "Y", label: "활성" },
{ value: "N", label: "비활성" },
] as const;
// 외부 호출 설정 타입 정의
export interface ExternalCallConfig { export interface ExternalCallConfig {
id?: number; id?: number;
config_name: string; config_name: string;
call_type: string; call_type: string;
api_type?: string; api_type?: string;
config_data: Record<string, unknown>; config_data: any;
description?: string; description?: string;
company_code?: string; company_code?: string;
is_active?: string; is_active?: string;
created_date?: string;
created_by?: string; created_by?: string;
updated_date?: string;
updated_by?: string; updated_by?: string;
} }
export interface ExternalCallConfigFilter { export interface ExternalCallConfigFilter {
company_code?: string; company_code?: string;
is_active?: string;
call_type?: string; call_type?: string;
api_type?: string; api_type?: string;
is_active?: string;
search?: string;
}
export interface ExternalCallConfigTestResult {
success: boolean;
message: string;
} }
/** /**
* API * API
*/ */
export class ExternalCallConfigAPI { export class ExternalCallConfigAPI {
private static readonly BASE_URL = "/external-call-configs";
/** /**
* *
*/ */
static async getConfigs(filter?: ExternalCallConfigFilter): Promise<ApiResponse<ExternalCallConfig[]>> { static async getConfigs(filter: ExternalCallConfigFilter = {}): Promise<{
success: boolean;
data?: ExternalCallConfig[];
error?: string;
}> {
try { try {
const params = new URLSearchParams(); const params = new URLSearchParams();
Object.entries(filter).forEach(([key, value]) => {
if (value) params.append(key, value);
});
if (filter?.company_code) params.append("company_code", filter.company_code); const response = await apiClient.get(`/external-call-configs?${params.toString()}`);
if (filter?.call_type) params.append("call_type", filter.call_type);
if (filter?.api_type) params.append("api_type", filter.api_type);
if (filter?.is_active) params.append("is_active", filter.is_active);
if (filter?.search) params.append("search", filter.search);
const url = params.toString() ? `${this.BASE_URL}?${params.toString()}` : this.BASE_URL;
const response = await apiClient.get(url);
return response.data; return response.data;
} catch (error) { } catch (error) {
console.error("외부호출 설정 목록 조회 실패:", error); console.error("외부호출 설정 목록 조회 실패:", error);
throw error; return {
success: false,
error: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.",
};
} }
} }
/** /**
* *
*/ */
static async getConfigById(id: number): Promise<ApiResponse<ExternalCallConfig>> { static async getConfigById(id: number): Promise<{
success: boolean;
data?: ExternalCallConfig;
error?: string;
}> {
try { try {
const response = await apiClient.get(`${this.BASE_URL}/${id}`); const response = await apiClient.get(`/external-call-configs/${id}`);
return response.data; return response.data;
} catch (error) { } catch (error) {
console.error(`외부 호출 설정 조회 실패 (ID: ${id}):`, error); console.error("외부호출 설정 상세 조회 실패:", error);
throw error; return {
success: false,
error: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.",
};
} }
} }
/** /**
* *
*/ */
static async createConfig( static async createConfig(data: Omit<ExternalCallConfig, "id">): Promise<{
config: Omit<ExternalCallConfig, "id" | "created_date" | "updated_date">, success: boolean;
): Promise<ApiResponse<ExternalCallConfig>> { data?: ExternalCallConfig;
error?: string;
}> {
try { try {
const response = await apiClient.post(this.BASE_URL, config); const response = await apiClient.post("/external-call-configs", data);
return response.data; return response.data;
} catch (error) { } catch (error) {
console.error("외부호출 설정 생성 실패:", error); console.error("외부호출 설정 생성 실패:", error);
throw error; return {
success: false,
error: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.",
};
} }
} }
/** /**
* *
*/ */
static async updateConfig(id: number, config: Partial<ExternalCallConfig>): Promise<ApiResponse<ExternalCallConfig>> { static async updateConfig(
id: number,
data: Partial<ExternalCallConfig>,
): Promise<{
success: boolean;
data?: ExternalCallConfig;
error?: string;
}> {
try { try {
const response = await apiClient.put(`${this.BASE_URL}/${id}`, config); const response = await apiClient.put(`/external-call-configs/${id}`, data);
return response.data; return response.data;
} catch (error) { } catch (error) {
console.error(`외부 호출 설정 수정 실패 (ID: ${id}):`, error); console.error("외부호출 설정 수정 실패:", error);
throw error; return {
success: false,
error: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.",
};
} }
} }
/** /**
* *
*/ */
static async deleteConfig(id: number): Promise<ApiResponse<void>> { static async deleteConfig(id: number): Promise<{
success: boolean;
error?: string;
}> {
try { try {
const response = await apiClient.delete(`${this.BASE_URL}/${id}`); const response = await apiClient.delete(`/external-call-configs/${id}`);
return response.data; return response.data;
} catch (error) { } catch (error) {
console.error(`외부 호출 설정 삭제 실패 (ID: ${id}):`, error); console.error("외부호출 설정 삭제 실패:", error);
throw error; return {
success: false,
error: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.",
};
} }
} }
/** /**
* *
*/ */
static async testConfig(id: number): Promise<ApiResponse<ExternalCallConfigTestResult>> { static async testConfig(id: number): Promise<{
success: boolean;
message?: string;
error?: string;
}> {
try { try {
const response = await apiClient.post(`${this.BASE_URL}/${id}/test`); const response = await apiClient.post(`/external-call-configs/${id}/test`);
return response.data; return response.data;
} catch (error) { } catch (error) {
console.error(`외부 호출 설정 테스트 실패 (ID: ${id}):`, error); console.error("외부호출 설정 테스트 실패:", error);
throw error; return {
success: false,
error: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.",
};
} }
} }
} }
// 호출 타입 옵션
export const CALL_TYPE_OPTIONS = [
{ value: "rest-api", label: "REST API" },
{ value: "email", label: "이메일" },
{ value: "ftp", label: "FTP" },
{ value: "queue", label: "큐" },
];
// API 타입 옵션 (REST API 전용)
export const API_TYPE_OPTIONS = [
{ value: "discord", label: "Discord" },
{ value: "slack", label: "Slack" },
{ value: "kakao-talk", label: "카카오톡" },
{ value: "mattermost", label: "Mattermost" },
{ value: "generic", label: "기타 (일반 API)" },
];
// 활성화 상태 옵션
export const ACTIVE_STATUS_OPTIONS = [
{ value: "Y", label: "활성" },
{ value: "N", label: "비활성" },
];

View File

@ -3,7 +3,7 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --turbopack", "dev": "next dev --turbopack -p 9771",
"build": "next build", "build": "next build",
"build:no-lint": "DISABLE_ESLINT_PLUGIN=true next build", "build:no-lint": "DISABLE_ESLINT_PLUGIN=true next build",
"start": "next start", "start": "next start",

View File

@ -0,0 +1,246 @@
/**
*
*
*/
// 외부호출 메인 설정 타입
export interface ExternalCallConfig {
callType: "rest-api"; // 향후 "webhook", "email", "ftp" 등 확장 가능
restApiSettings: RestApiSettings;
metadata?: {
createdAt: string;
updatedAt: string;
version: string;
};
}
// REST API 설정 타입
export interface RestApiSettings {
// 기본 설정
apiUrl: string;
httpMethod: "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
// 헤더 설정
headers: Record<string, string>;
// 요청 바디 (POST/PUT/PATCH 시 사용)
bodyTemplate: string;
// 인증 설정
authentication?: AuthenticationConfig;
// 고급 설정
timeout?: number; // 초 단위
retryCount?: number;
retryDelay?: number; // 밀리초 단위
// 응답 처리 설정
responseHandling?: ResponseHandlingConfig;
}
// 인증 설정 타입
export interface AuthenticationConfig {
type: "none" | "bearer" | "basic" | "api-key" | "oauth2";
// Bearer Token 인증
token?: string;
// Basic 인증
username?: string;
password?: string;
// API Key 인증
apiKey?: string;
apiKeyHeader?: string; // 기본값: "X-API-Key"
apiKeyLocation?: "header" | "query"; // API Key 위치
apiKeyQueryParam?: string; // 쿼리 파라미터명 (location이 query일 때)
// OAuth2 (향후 확장)
clientId?: string;
clientSecret?: string;
tokenUrl?: string;
}
// 응답 처리 설정 타입
export interface ResponseHandlingConfig {
// 성공 상태 코드 범위
successStatusCodes?: number[];
// 응답 데이터 저장 여부
saveResponse?: boolean;
// 응답 데이터 파싱 설정
responseParser?: {
type: "json" | "xml" | "text";
extractPath?: string; // JSONPath 또는 XPath
};
// 오류 처리 설정
errorHandling?: {
continueOnError: boolean;
logErrors: boolean;
notifyOnError: boolean;
};
}
// 템플릿 변수 타입
export interface TemplateVariable {
key: string;
description: string;
example: string;
type: "string" | "number" | "object" | "array";
}
// 사용 가능한 템플릿 변수 목록
export const AVAILABLE_TEMPLATE_VARIABLES: TemplateVariable[] = [
{
key: "sourceData",
description: "소스 노드의 전체 데이터",
example: "{{sourceData}}",
type: "object",
},
{
key: "targetData",
description: "타겟 노드의 데이터 (있는 경우)",
example: "{{targetData}}",
type: "object",
},
{
key: "timestamp",
description: "현재 타임스탬프 (ISO 8601 형식)",
example: "{{timestamp}}",
type: "string",
},
{
key: "relationshipId",
description: "현재 관계의 고유 ID",
example: "{{relationshipId}}",
type: "string",
},
{
key: "diagramId",
description: "현재 다이어그램의 고유 ID",
example: "{{diagramId}}",
type: "string",
},
{
key: "userId",
description: "현재 사용자의 ID",
example: "{{userId}}",
type: "string",
},
{
key: "executionId",
description: "현재 실행의 고유 ID",
example: "{{executionId}}",
type: "string",
},
];
// API 테스트 결과 타입
export interface ApiTestResult {
success: boolean;
statusCode?: number;
responseTime: number; // 밀리초
response?: string; // 백엔드에서 response 필드로 응답 텍스트 전송
error?: string;
timestamp: string;
}
// API 실행 결과 타입
export interface ApiExecutionResult extends ApiTestResult {
executionId: string;
relationshipId: string;
diagramId: string;
retryCount?: number;
finalAttempt: boolean;
}
// 외부호출 실행 컨텍스트
export interface ExternalCallContext {
relationshipId: string;
diagramId: string;
userId: string;
executionId: string;
sourceData: Record<string, any>;
targetData?: Record<string, any>;
timestamp: string;
metadata?: Record<string, any>;
}
// 외부호출 로그 타입
export interface ExternalCallLog {
id: string;
relationshipId: string;
diagramId: string;
executionId: string;
callType: string;
apiUrl: string;
httpMethod: string;
requestHeaders: Record<string, string>;
requestBody?: string;
responseStatusCode?: number;
responseHeaders?: Record<string, string>;
responseBody?: string;
responseTime: number;
success: boolean;
error?: string;
retryCount: number;
createdAt: string;
}
// 외부호출 통계 타입
export interface ExternalCallStats {
totalCalls: number;
successfulCalls: number;
failedCalls: number;
averageResponseTime: number;
lastExecuted?: string;
mostUsedEndpoint?: string;
errorRate: number;
}
// 프리셋 API 설정 타입 (자주 사용되는 API 설정 저장용)
export interface ApiPreset {
id: string;
name: string;
description: string;
category: "webhook" | "notification" | "integration" | "custom";
settings: RestApiSettings;
isPublic: boolean;
createdBy: string;
createdAt: string;
updatedAt: string;
usageCount: number;
}
// 외부호출 검증 결과 타입
export interface ExternalCallValidationResult {
valid: boolean;
errors: string[];
warnings: string[];
suggestions?: string[];
}
// 외부호출 패널 Props 타입
export interface ExternalCallPanelProps {
relationshipId: string;
onSettingsChange: (settings: ExternalCallConfig) => void;
initialSettings?: ExternalCallConfig;
readonly?: boolean;
}
// REST API 설정 컴포넌트 Props 타입
export interface RestApiSettingsProps {
settings: RestApiSettings;
onSettingsChange: (settings: RestApiSettings) => void;
readonly?: boolean;
}
// API 테스트 패널 Props 타입
export interface ExternalCallTestPanelProps {
settings: RestApiSettings;
context?: Partial<ExternalCallContext>;
onTestResult: (result: ApiTestResult) => void;
disabled?: boolean;
}

View File

@ -0,0 +1,364 @@
/**
* REST API
* REST API
*/
// HTTP 메서드 타입
export type HttpMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "HEAD" | "OPTIONS";
// Content-Type 타입
export type ContentType =
| "application/json"
| "application/xml"
| "application/x-www-form-urlencoded"
| "multipart/form-data"
| "text/plain"
| "text/html"
| "text/xml";
// 인증 타입별 세부 설정
export interface BearerTokenAuth {
type: "bearer";
token: string;
tokenPrefix?: string; // 기본값: "Bearer"
}
export interface BasicAuth {
type: "basic";
username: string;
password: string;
}
export interface ApiKeyAuth {
type: "api-key";
apiKey: string;
location: "header" | "query";
keyName: string; // 헤더명 또는 쿼리 파라미터명
}
export interface OAuth2Auth {
type: "oauth2";
clientId: string;
clientSecret: string;
tokenUrl: string;
scope?: string;
grantType: "client_credentials" | "authorization_code" | "password";
}
export interface NoAuth {
type: "none";
}
// 통합 인증 타입
export type AuthenticationType = BearerTokenAuth | BasicAuth | ApiKeyAuth | OAuth2Auth | NoAuth;
// HTTP 헤더 프리셋
export interface HeaderPreset {
name: string;
headers: Record<string, string>;
description: string;
}
export const COMMON_HEADER_PRESETS: HeaderPreset[] = [
{
name: "JSON API",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
description: "JSON 데이터 송수신용 헤더",
},
{
name: "Form Data",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
description: "폼 데이터 전송용 헤더",
},
{
name: "XML API",
headers: {
"Content-Type": "application/xml",
Accept: "application/xml",
},
description: "XML 데이터 송수신용 헤더",
},
{
name: "File Upload",
headers: {
"Content-Type": "multipart/form-data",
},
description: "파일 업로드용 헤더",
},
];
// 요청 바디 템플릿 타입
export interface RequestBodyTemplate {
type: "json" | "xml" | "form" | "text" | "raw";
template: string;
variables: string[]; // 템플릿에서 사용된 변수 목록
}
// JSON 바디 템플릿 예제
export const JSON_BODY_TEMPLATES = {
simple: {
name: "간단한 메시지",
template: `{
"message": "{{message}}",
"timestamp": "{{timestamp}}"
}`,
},
webhook: {
name: "웹훅 알림",
template: `{
"event": "data_updated",
"data": {{sourceData}},
"metadata": {
"relationshipId": "{{relationshipId}}",
"executedAt": "{{timestamp}}",
"executedBy": "{{userId}}"
}
}`,
},
slack: {
name: "슬랙 메시지",
template: `{
"text": "데이터가 업데이트되었습니다",
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "*데이터 변경 알림*\\n관계 ID: {{relationshipId}}\\n실행 시간: {{timestamp}}"
}
},
{
"type": "section",
"fields": [
{
"type": "mrkdwn",
"text": "*소스 데이터:*\\n\`\`\`{{sourceData}}\`\`\`"
}
]
}
]
}`,
},
discord: {
name: "디스코드 웹훅",
template: `{
"username": "ERP 시스템",
"content": "데이터가 업데이트되었습니다.",
"embeds": [
{
"title": "데이터 변경 알림",
"description": "관계 실행이 완료되었습니다.",
"color": 5814783,
"fields": [
{
"name": "관계 ID",
"value": "{{relationshipId}}",
"inline": true
},
{
"name": "실행 시간",
"value": "{{timestamp}}",
"inline": true
}
],
"footer": {
"text": "ERP 제어관리 시스템"
}
}
]
}`,
},
};
// 응답 상태 코드 그룹
export interface StatusCodeGroup {
name: string;
codes: number[];
description: string;
isSuccess: boolean;
}
export const HTTP_STATUS_GROUPS: StatusCodeGroup[] = [
{
name: "성공 (2xx)",
codes: [200, 201, 202, 204],
description: "요청이 성공적으로 처리됨",
isSuccess: true,
},
{
name: "리다이렉션 (3xx)",
codes: [301, 302, 304],
description: "추가 작업이 필요함",
isSuccess: false,
},
{
name: "클라이언트 오류 (4xx)",
codes: [400, 401, 403, 404, 422, 429],
description: "클라이언트 요청에 오류가 있음",
isSuccess: false,
},
{
name: "서버 오류 (5xx)",
codes: [500, 502, 503, 504],
description: "서버에서 오류가 발생함",
isSuccess: false,
},
];
// 재시도 정책 타입
export interface RetryPolicy {
enabled: boolean;
maxRetries: number;
retryDelay: number; // 밀리초
backoffStrategy: "fixed" | "exponential" | "linear";
retryOnStatusCodes: number[]; // 재시도할 상태 코드
retryOnTimeout: boolean;
}
// 기본 재시도 정책
export const DEFAULT_RETRY_POLICY: RetryPolicy = {
enabled: true,
maxRetries: 3,
retryDelay: 1000,
backoffStrategy: "exponential",
retryOnStatusCodes: [429, 500, 502, 503, 504],
retryOnTimeout: true,
};
// 타임아웃 설정 타입
export interface TimeoutConfig {
connection: number; // 연결 타임아웃 (밀리초)
request: number; // 요청 타임아웃 (밀리초)
response: number; // 응답 타임아웃 (밀리초)
}
// 기본 타임아웃 설정
export const DEFAULT_TIMEOUT_CONFIG: TimeoutConfig = {
connection: 5000, // 5초
request: 30000, // 30초
response: 30000, // 30초
};
// API 엔드포인트 프리셋
export interface ApiEndpointPreset {
id: string;
name: string;
category: "webhook" | "notification" | "integration" | "monitoring";
baseUrl: string;
defaultMethod: HttpMethod;
defaultHeaders: Record<string, string>;
authType: AuthenticationType["type"];
description: string;
documentation?: string;
examples: RequestBodyTemplate[];
}
// 인기 있는 API 서비스 프리셋
export const POPULAR_API_PRESETS: ApiEndpointPreset[] = [
{
id: "slack-webhook",
name: "Slack 웹훅",
category: "notification",
baseUrl: "https://hooks.slack.com/services/",
defaultMethod: "POST",
defaultHeaders: {
"Content-Type": "application/json",
},
authType: "none",
description: "Slack 채널로 메시지 전송",
examples: [
{
type: "json",
template: JSON_BODY_TEMPLATES.slack.template,
variables: ["relationshipId", "timestamp", "sourceData"],
},
],
},
{
id: "discord-webhook",
name: "Discord 웹훅",
category: "notification",
baseUrl: "https://discord.com/api/webhooks/",
defaultMethod: "POST",
defaultHeaders: {
"Content-Type": "application/json",
},
authType: "none",
description: "Discord 채널로 메시지 전송",
examples: [
{
type: "json",
template: JSON_BODY_TEMPLATES.discord.template,
variables: ["relationshipId", "timestamp"],
},
],
},
{
id: "generic-webhook",
name: "일반 웹훅",
category: "webhook",
baseUrl: "https://api.example.com/webhook",
defaultMethod: "POST",
defaultHeaders: {
"Content-Type": "application/json",
},
authType: "bearer",
description: "일반적인 웹훅 엔드포인트",
examples: [
{
type: "json",
template: JSON_BODY_TEMPLATES.webhook.template,
variables: ["sourceData", "relationshipId", "timestamp", "userId"],
},
],
},
];
// URL 검증 결과 타입
export interface UrlValidationResult {
valid: boolean;
protocol?: string;
hostname?: string;
port?: number;
pathname?: string;
errors: string[];
}
// 헤더 검증 결과 타입
export interface HeaderValidationResult {
valid: boolean;
warnings: string[];
errors: string[];
suggestions: string[];
}
// JSON 템플릿 검증 결과 타입
export interface JsonTemplateValidationResult {
valid: boolean;
parseable: boolean;
variables: string[];
errors: string[];
warnings: string[];
}
// REST API 설정 전체 검증 결과
export interface RestApiValidationResult {
valid: boolean;
url: UrlValidationResult;
headers: HeaderValidationResult;
body: JsonTemplateValidationResult;
auth: {
valid: boolean;
errors: string[];
};
overall: {
errors: string[];
warnings: string[];
suggestions: string[];
};
}

View File

@ -0,0 +1,472 @@
# 🌐 제어관리 외부호출 REST API 기능 구현 계획서
## 📋 프로젝트 개요
### 목적
제어관리 시스템에서 관계 생성 시 연결타입으로 "외부호출"을 선택하면 REST API 호출 기능을 설정할 수 있도록 구현합니다.
### 현재 상황 분석
#### 기존 연결타입 구조
- **데이터 저장**: INSERT/UPDATE/DELETE 작업 지원
- **외부 호출**: 기본 구조는 있으나 REST API 전용 설정 UI 필요
#### 기존 외부호출 기능
- `ExternalCallSettings.tsx`: 복합적인 외부호출 설정 (REST API, 이메일, FTP, 큐)
- `SimpleExternalCallSettings.tsx`: 기존 설정을 재사용하는 단순화된 버전
- `ExternalCallAPI`: REST API 테스트/실행 기능 완료
- `ExternalCallService`: 백엔드 로직 완료
## 🎯 구현 목표
### 1단계: REST API 전용 설정 컴포넌트 개발
기존 복합 설정에서 REST API 부분만 추출하여 전용 컴포넌트 생성
### 2단계: 연결타입 선택 시 REST API 설정 연동
ConnectionTypeSelector에서 "외부호출" 선택 시 REST API 설정 패널 표시
### 3단계: 설정 저장 및 실행 로직 연동
관계 데이터에 REST API 설정 저장 및 실행 시점 연동
## 🏗️ 시스템 아키텍처 설계
### 1. 분리된 외부호출 컴포넌트 구조
#### 🔄 설계 철학: 완전 분리형 아키텍처
기존 데이터 저장 기능과 완전히 독립된 외부호출 전용 컴포넌트들을 구성하여 각각의 책임을 명확히 분리합니다.
#### ExternalCallPanel.tsx (신규 생성 - 메인 패널)
```typescript
interface ExternalCallPanelProps {
relationshipId: string;
onSettingsChange: (settings: ExternalCallConfig) => void;
initialSettings?: ExternalCallConfig;
}
// 외부호출 전용 설정 구조
interface ExternalCallConfig {
callType: "rest-api"; // 향후 확장 가능
restApiSettings: RestApiSettings;
}
```
#### RestApiSettings.tsx (신규 생성 - REST API 전용)
```typescript
interface RestApiSettingsProps {
settings: RestApiSettings;
onSettingsChange: (settings: RestApiSettings) => void;
}
interface RestApiSettings {
apiUrl: string;
httpMethod: "GET" | "POST" | "PUT" | "DELETE";
headers: Record<string, string>;
bodyTemplate: string;
authentication?: {
type: "none" | "bearer" | "basic" | "api-key";
token?: string;
username?: string;
password?: string;
apiKey?: string;
apiKeyHeader?: string;
};
timeout?: number;
retryCount?: number;
}
```
#### ExternalCallTestPanel.tsx (신규 생성 - 테스트 전용)
```typescript
interface ExternalCallTestPanelProps {
settings: RestApiSettings;
onTestResult: (result: TestResult) => void;
}
```
#### 주요 기능
- **URL 설정**: API 엔드포인트 URL 입력
- **HTTP 메서드**: GET, POST, PUT, DELETE 선택
- **헤더 설정**: Content-Type, Authorization 등 커스텀 헤더
- **바디 템플릿**: POST/PUT 요청 시 JSON 바디 템플릿
- **인증 설정**: Bearer Token, Basic Auth, API Key 지원
- **고급 설정**: 타임아웃, 재시도 횟수
- **테스트 기능**: 설정한 API 실제 호출 테스트
### 2. 연결타입 선택 플로우 개선
#### ConnectionTypeSelector 수정
```typescript
// 기존 코드 유지, 외부호출 선택 시 이벤트 처리만 추가
const connectionTypes: ConnectionType[] = [
{
id: "data_save",
label: "데이터 저장",
description: "INSERT/UPDATE/DELETE 작업",
icon: <Database className="h-4 w-4" />,
},
{
id: "external_call",
label: "외부 호출",
description: "REST API 호출", // 설명 업데이트
icon: <Globe className="h-4 w-4" />,
},
];
```
#### ConnectionSetupModal 수정
```typescript
// 외부호출 선택 시 완전 분리된 ExternalCallPanel 표시
{
selectedConnectionType === "external_call" && (
<ExternalCallPanel
relationshipId={relationshipId}
initialSettings={externalCallConfig}
onSettingsChange={setExternalCallConfig}
/>
);
}
// 데이터 저장 선택 시 기존 DataSavePanel 표시
{
selectedConnectionType === "data_save" && (
<DataSavePanel
relationshipId={relationshipId}
initialSettings={dataSaveConfig}
onSettingsChange={setDataSaveConfig}
/>
);
}
```
### 3. 데이터 구조 확장
#### 관계 데이터 스키마 확장
```sql
-- relationships 테이블에 rest_api_settings 컬럼 추가 (JSON 타입)
ALTER TABLE relationships
ADD COLUMN rest_api_settings JSONB;
-- 인덱스 추가 (성능 최적화)
CREATE INDEX idx_relationships_rest_api_settings
ON relationships USING GIN (rest_api_settings);
```
#### TypeScript 타입 정의
```typescript
// types/connectionTypes.ts 확장
export interface RelationshipData {
id: string;
sourceNodeId: string;
targetNodeId: string;
connectionType: "data_save" | "external_call";
// 기존 필드들...
dataSaveSettings?: DataSaveSettings;
// 새로 추가
restApiSettings?: RestApiSettings;
}
```
## 📱 UI/UX 설계
### 1. REST API 설정 패널 레이아웃
```
┌─────────────────────────────────────────────────────────┐
│ 🌐 REST API 설정 [테스트] 버튼 │
├─────────────────────────────────────────────────────────┤
│ API URL │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ https://api.example.com/webhook │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ HTTP 메서드 │ 인증 방식 │
│ ┌─────────────┐ │ ┌─────────────────────────────────┐ │
│ │ POST ▼ │ │ │ Bearer Token ▼ │ │
│ └─────────────┘ │ └─────────────────────────────────┘ │
│ │
│ 헤더 설정 │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Content-Type: application/json │ │
│ │ Authorization: Bearer {token} │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 요청 바디 템플릿 │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ { │ │
│ │ "message": "{{message}}", │ │
│ │ "data": {{sourceData}}, │ │
│ │ "timestamp": "{{timestamp}}" │ │
│ │ } │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 고급 설정 │
│ 타임아웃: [30] 초 재시도: [3] 회 │
└─────────────────────────────────────────────────────────┘
```
### 2. 템플릿 변수 지원
#### 사용 가능한 템플릿 변수
- `{{sourceData}}`: 소스 노드의 전체 데이터
- `{{targetData}}`: 타겟 노드의 데이터 (있는 경우)
- `{{timestamp}}`: 현재 타임스탬프
- `{{relationshipId}}`: 관계 ID
- `{{diagramId}}`: 다이어그램 ID
- `{{userId}}`: 현재 사용자 ID
#### 템플릿 도우미 UI
```typescript
const TemplateHelper = () => (
<div className="text-xs text-gray-600 mt-2">
<details>
<summary className="cursor-pointer">사용 가능한 템플릿 변수</summary>
<div className="mt-2 space-y-1">
<div>
<code>{"{{sourceData}}"}</code> - 소스 노드 데이터
</div>
<div>
<code>{"{{timestamp}}"}</code> - 현재 시간
</div>
<div>
<code>{"{{relationshipId}}"}</code> - 관계 ID
</div>
</div>
</details>
</div>
);
```
## 🔧 구현 단계별 계획
### Phase 1: 기본 REST API 설정 컴포넌트 (1-2일)
#### 1.1 분리된 컴포넌트 구조 생성
- [ ] **ExternalCallPanel.tsx**: 외부호출 메인 패널 컴포넌트
- [ ] **RestApiSettings.tsx**: REST API 전용 설정 컴포넌트
- [ ] **ExternalCallTestPanel.tsx**: API 테스트 전용 컴포넌트
- [ ] **외부호출 전용 디렉토리**: `components/dataflow/external-call/` 생성
#### 1.2 타입 정의 분리
- [ ] **ExternalCallTypes.ts**: 외부호출 전용 타입 정의 파일 생성
- [ ] **RestApiTypes.ts**: REST API 전용 타입 정의
- [ ] 기존 `connectionTypes.ts`와 완전 분리하여 독립성 확보
### Phase 2: 연결타입 선택 연동 (1일)
#### 2.1 ConnectionSetupModal 수정
- [ ] 외부호출 선택 시 RestApiSettings 컴포넌트 표시
- [ ] 상태 관리 로직 추가
- [ ] 설정 저장/불러오기 연동
#### 2.2 ConnectionTypeSelector 설명 업데이트
- [ ] 외부호출 설명을 "REST API 호출"로 명확화
### Phase 3: 인증 및 고급 기능 (2-3일)
#### 3.1 인증 방식 지원
- [ ] Bearer Token 인증
- [ ] Basic Authentication
- [ ] API Key 인증 (헤더/쿼리 파라미터)
- [ ] 인증 정보 암호화 저장
#### 3.2 고급 설정
- [ ] 타임아웃 설정
- [ ] 재시도 로직
- [ ] 응답 검증 규칙
### Phase 4: 테스트 및 실행 기능 (1-2일)
#### 4.1 테스트 기능 구현
- [ ] 설정한 API 실제 호출 테스트
- [ ] 응답 결과 표시
- [ ] 오류 메시지 상세 표시
#### 4.2 실행 로직 연동
- [ ] 관계 실행 시 REST API 호출
- [ ] 템플릿 변수 치환
- [ ] 실행 결과 로깅
### Phase 5: 템플릿 시스템 고도화 (2-3일)
#### 5.1 템플릿 변수 확장
- [ ] 소스/타겟 데이터 접근
- [ ] 조건부 템플릿 (if/else)
- [ ] 반복 템플릿 (for loop)
#### 5.2 템플릿 에디터 개선
- [ ] 문법 하이라이팅
- [ ] 자동완성
- [ ] 실시간 미리보기
### Phase 6: 데이터베이스 및 백엔드 연동 (1일)
#### 6.1 데이터베이스 스키마 업데이트
- [ ] relationships 테이블에 rest_api_settings 컬럼 추가
- [ ] 마이그레이션 스크립트 작성
#### 6.2 백엔드 API 수정
- [ ] 관계 저장 시 REST API 설정 저장
- [ ] 관계 실행 시 REST API 호출 로직
## 🧪 테스트 계획
### 1. 단위 테스트
- [ ] RestApiSettings 컴포넌트 렌더링 테스트
- [ ] 입력값 검증 로직 테스트
- [ ] 템플릿 변수 치환 테스트
### 2. 통합 테스트
- [ ] 연결타입 선택 → REST API 설정 플로우
- [ ] 설정 저장 → 불러오기 테스트
- [ ] API 호출 → 응답 처리 테스트
### 3. E2E 테스트
- [ ] 전체 관계 생성 → 실행 플로우
- [ ] 다양한 API 서비스와의 연동 테스트
- [ ] 오류 상황 처리 테스트
## 🔒 보안 고려사항
### 1. 인증 정보 보호
- [ ] API 키, 토큰 등 민감 정보 암호화 저장
- [ ] 프론트엔드에서 민감 정보 마스킹 표시
- [ ] 로그에 민감 정보 노출 방지
### 2. API 호출 제한
- [ ] 호출 빈도 제한 (Rate Limiting)
- [ ] 허용된 도메인만 호출 가능하도록 제한
- [ ] 타임아웃 설정으로 무한 대기 방지
### 3. 입력값 검증
- [ ] URL 형식 검증
- [ ] JSON 템플릿 문법 검증
- [ ] XSS, 인젝션 공격 방지
## 📊 성능 최적화
### 1. 프론트엔드 최적화
- [ ] 컴포넌트 지연 로딩 (Lazy Loading)
- [ ] 설정 변경 시 디바운싱 적용
- [ ] 메모이제이션으로 불필요한 리렌더링 방지
### 2. 백엔드 최적화
- [ ] API 호출 결과 캐싱
- [ ] 비동기 처리로 응답 시간 단축
- [ ] 연결 풀링으로 리소스 효율화
## 🚀 배포 및 모니터링
### 1. 배포 전략
- [ ] 기능 플래그로 점진적 배포
- [ ] A/B 테스트로 사용성 검증
- [ ] 롤백 계획 수립
### 2. 모니터링
- [ ] API 호출 성공/실패율 모니터링
- [ ] 응답 시간 추적
- [ ] 오류 로그 수집 및 알림
## 📝 문서화
### 1. 사용자 가이드
- [ ] REST API 설정 방법 가이드
- [ ] 템플릿 변수 사용법
- [ ] 일반적인 API 연동 예제
### 2. 개발자 문서
- [ ] 컴포넌트 API 문서
- [ ] 백엔드 API 명세
- [ ] 확장 가이드
## 🎯 성공 지표
### 1. 기능적 지표
- [ ] REST API 설정 완료율 > 95%
- [ ] API 호출 성공률 > 98%
- [ ] 설정 저장/불러오기 성공률 > 99%
### 2. 사용성 지표
- [ ] 설정 완료 시간 < 5분
- [ ] 사용자 만족도 > 4.0/5.0
- [ ] 지원 요청 감소율 > 30%
## 📅 일정 계획
| 단계 | 기간 | 주요 작업 |
| ----------- | ---------- | ---------------------------------- |
| Phase 1 | 1-2일 | RestApiSettings 컴포넌트 기본 구현 |
| Phase 2 | 1일 | 연결타입 선택 연동 |
| Phase 3 | 2-3일 | 인증 및 고급 기능 |
| Phase 4 | 1-2일 | 테스트 및 실행 기능 |
| Phase 5 | 2-3일 | 템플릿 시스템 고도화 |
| Phase 6 | 1일 | 데이터베이스 및 백엔드 연동 |
| **총 기간** | **8-12일** | **전체 기능 완성** |
## 🔄 향후 확장 계획
### 1. 추가 프로토콜 지원
- [ ] GraphQL API 지원
- [ ] WebSocket 연결
- [ ] gRPC 호출
### 2. 고급 기능
- [ ] API 응답 기반 조건부 실행
- [ ] 여러 API 순차/병렬 호출
- [ ] API 응답 데이터 파싱 및 저장
### 3. 통합 기능
- [ ] 외부 API 문서 자동 가져오기
- [ ] Postman 컬렉션 임포트
- [ ] OpenAPI 스펙 지원
---
이 계획서를 바탕으로 단계별로 구현을 진행하면 안정적이고 사용자 친화적인 REST API 외부호출 기능을 완성할 수 있습니다.