외부호출 기능(rest API)
This commit is contained in:
parent
9454e3a81f
commit
11b71b788a
|
|
@ -35,6 +35,8 @@ import multiConnectionRoutes from "./routes/multiConnectionRoutes";
|
|||
import dbTypeCategoryRoutes from "./routes/dbTypeCategoryRoutes";
|
||||
import ddlRoutes from "./routes/ddlRoutes";
|
||||
import entityReferenceRoutes from "./routes/entityReferenceRoutes";
|
||||
import externalCallRoutes from "./routes/externalCallRoutes";
|
||||
import externalCallConfigRoutes from "./routes/externalCallConfigRoutes";
|
||||
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
|
||||
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
|
||||
// import userRoutes from './routes/userRoutes';
|
||||
|
|
@ -88,7 +90,7 @@ app.use(
|
|||
// Rate Limiting (개발 환경에서는 완화)
|
||||
const limiter = rateLimit({
|
||||
windowMs: 1 * 60 * 1000, // 1분
|
||||
max: config.nodeEnv === "development" ? 10000 : 100, // 개발환경에서는 10000으로 증가, 운영환경에서는 100
|
||||
max: config.nodeEnv === "development" ? 10000 : 1000, // 개발환경에서는 10000으로 증가, 운영환경에서는 100
|
||||
message: {
|
||||
error: "너무 많은 요청이 발생했습니다. 잠시 후 다시 시도해주세요.",
|
||||
},
|
||||
|
|
@ -142,6 +144,8 @@ app.use("/api/multi-connection", multiConnectionRoutes);
|
|||
app.use("/api/db-type-categories", dbTypeCategoryRoutes);
|
||||
app.use("/api/ddl", ddlRoutes);
|
||||
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/batch", batchRoutes); // 임시 주석
|
||||
// app.use('/api/users', userRoutes);
|
||||
|
|
|
|||
|
|
@ -180,10 +180,57 @@ export class ExternalCallService {
|
|||
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({
|
||||
url: settings.url,
|
||||
method: settings.method,
|
||||
headers: settings.headers || {},
|
||||
headers: headers,
|
||||
body: body,
|
||||
timeout: settings.timeout || this.DEFAULT_TIMEOUT,
|
||||
});
|
||||
|
|
@ -213,17 +260,36 @@ export class ExternalCallService {
|
|||
const controller = new AbortController();
|
||||
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,
|
||||
headers: options.headers,
|
||||
body: options.body,
|
||||
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);
|
||||
|
||||
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 {
|
||||
success: response.ok,
|
||||
statusCode: response.status,
|
||||
|
|
|
|||
|
|
@ -53,14 +53,26 @@ export interface DiscordSettings extends ExternalCallConfig {
|
|||
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 설정
|
||||
export interface GenericApiSettings extends ExternalCallConfig {
|
||||
callType: "rest-api";
|
||||
apiType: "generic";
|
||||
url: string;
|
||||
method: "GET" | "POST" | "PUT" | "DELETE";
|
||||
method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "HEAD";
|
||||
headers?: Record<string, string>;
|
||||
body?: string;
|
||||
authentication?: AuthenticationSettings;
|
||||
}
|
||||
|
||||
// 이메일 설정
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import {
|
|||
SimpleExternalCallSettings,
|
||||
ConnectionSetupModalProps,
|
||||
} from "@/types/connectionTypes";
|
||||
import { ExternalCallConfig } from "@/types/external-call/ExternalCallTypes";
|
||||
import { isConditionalConnection } from "@/utils/connectionUtils";
|
||||
import { useConditionManager } from "@/hooks/useConditionManager";
|
||||
import { ConditionalSettings } from "./condition/ConditionalSettings";
|
||||
|
|
@ -30,6 +31,7 @@ import { ConnectionTypeSelector } from "./connection/ConnectionTypeSelector";
|
|||
import { SimpleKeySettings as SimpleKeySettingsComponent } from "./connection/SimpleKeySettings";
|
||||
import { DataSaveSettings as DataSaveSettingsComponent } from "./connection/DataSaveSettings";
|
||||
import { SimpleExternalCallSettings as ExternalCallSettingsComponent } from "./connection/SimpleExternalCallSettings";
|
||||
import ExternalCallPanel from "./external-call/ExternalCallPanel";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
|
||||
|
|
@ -60,6 +62,9 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
|
|||
message: "",
|
||||
});
|
||||
|
||||
// 새로운 외부호출 설정 상태 (분리된 컴포넌트용)
|
||||
const [externalCallConfig, setExternalCallConfig] = useState<ExternalCallConfig | null>(null);
|
||||
|
||||
// 테이블 및 컬럼 선택을 위한 상태들
|
||||
const [availableTables, setAvailableTables] = useState<TableInfo[]>([]);
|
||||
const [selectedFromTable, setSelectedFromTable] = useState<string>("");
|
||||
|
|
@ -390,14 +395,21 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
|
|||
}
|
||||
break;
|
||||
case "external-call":
|
||||
// 외부 호출은 plan에 저장
|
||||
plan = {
|
||||
externalCall: {
|
||||
configId: externalCallSettings.configId,
|
||||
configName: externalCallSettings.configName,
|
||||
message: externalCallSettings.message,
|
||||
},
|
||||
};
|
||||
// 새로운 외부호출 설정을 plan에 저장
|
||||
if (externalCallConfig) {
|
||||
plan = {
|
||||
externalCall: externalCallConfig,
|
||||
};
|
||||
} else {
|
||||
// 기존 설정 호환성 유지
|
||||
plan = {
|
||||
externalCall: {
|
||||
configId: externalCallSettings.configId,
|
||||
configName: externalCallSettings.configName,
|
||||
message: externalCallSettings.message,
|
||||
},
|
||||
};
|
||||
}
|
||||
settings = {}; // 외부 호출은 settings에 저장하지 않음
|
||||
break;
|
||||
}
|
||||
|
|
@ -507,6 +519,9 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
|
|||
|
||||
// 연결 종류별 설정 패널 렌더링
|
||||
const renderConnectionTypeSettings = () => {
|
||||
console.log("🔍 [ConnectionSetupModal] renderConnectionTypeSettings - connectionType:", config.connectionType);
|
||||
console.log("🔍 [ConnectionSetupModal] externalCallConfig:", externalCallConfig);
|
||||
|
||||
switch (config.connectionType) {
|
||||
case "simple-key":
|
||||
return (
|
||||
|
|
@ -540,8 +555,13 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
|
|||
);
|
||||
|
||||
case "external-call":
|
||||
console.log("🚀 [ConnectionSetupModal] Rendering ExternalCallPanel");
|
||||
return (
|
||||
<ExternalCallSettingsComponent settings={externalCallSettings} onSettingsChange={setExternalCallSettings} />
|
||||
<ExternalCallPanel
|
||||
relationshipId={connection?.id || `temp-${Date.now()}`}
|
||||
initialSettings={externalCallConfig}
|
||||
onSettingsChange={setExternalCallConfig}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
|
|
@ -631,8 +651,12 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
|
|||
return !hasActions || !allActionsHaveMappings || !allMappingsComplete || !allRequiredConditionsMet;
|
||||
|
||||
case "external-call":
|
||||
// 외부 호출: 설정 ID와 메시지가 있어야 함
|
||||
return !externalCallSettings.configId || !externalCallSettings.message?.trim();
|
||||
// 외부 호출: 새로운 설정이 있으면 API URL 검증, 없으면 기존 설정 검증
|
||||
if (externalCallConfig) {
|
||||
return !externalCallConfig.restApiSettings?.apiUrl?.trim();
|
||||
} else {
|
||||
return !externalCallSettings.configId || !externalCallSettings.message?.trim();
|
||||
}
|
||||
|
||||
default:
|
||||
return false;
|
||||
|
|
|
|||
|
|
@ -47,7 +47,10 @@ export const ConnectionTypeSelector: React.FC<ConnectionTypeSelectorProps> = ({
|
|||
? "border-orange-500 bg-orange-50"
|
||||
: "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" />
|
||||
<div className="mt-1 text-xs font-medium">외부 호출</div>
|
||||
|
|
|
|||
|
|
@ -114,6 +114,9 @@ const DataConnectionDesigner: React.FC<DataConnectionDesignerProps> = ({
|
|||
controlConditions: initialData.controlConditions || prev.controlConditions,
|
||||
fieldMappings: initialData.fieldMappings || prev.fieldMappings,
|
||||
|
||||
// 🔧 외부호출 설정 로드
|
||||
externalCallConfig: initialData.externalCallConfig || prev.externalCallConfig,
|
||||
|
||||
// 🔧 액션 그룹 데이터 로드 (기존 호환성 포함)
|
||||
actionGroups:
|
||||
initialData.actionGroups ||
|
||||
|
|
@ -155,6 +158,7 @@ const DataConnectionDesigner: React.FC<DataConnectionDesignerProps> = ({
|
|||
const actions: DataConnectionActions = {
|
||||
// 연결 타입 설정
|
||||
setConnectionType: useCallback((type: "data_save" | "external_call") => {
|
||||
console.log("🔄 [DataConnectionDesigner] setConnectionType 호출됨:", type);
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
connectionType: type,
|
||||
|
|
@ -376,6 +380,15 @@ const DataConnectionDesigner: React.FC<DataConnectionDesignerProps> = ({
|
|||
toast.success("제어 조건이 삭제되었습니다.");
|
||||
}, []),
|
||||
|
||||
// 외부호출 설정 업데이트
|
||||
updateExternalCallConfig: useCallback((config: any) => {
|
||||
console.log("🔄 외부호출 설정 업데이트:", config);
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
externalCallConfig: config,
|
||||
}));
|
||||
}, []),
|
||||
|
||||
// 액션 설정 관리
|
||||
setActionType: useCallback((type: "insert" | "update" | "delete" | "upsert") => {
|
||||
setState((prev) => ({
|
||||
|
|
@ -534,6 +547,15 @@ const DataConnectionDesigner: React.FC<DataConnectionDesignerProps> = ({
|
|||
return;
|
||||
}
|
||||
|
||||
// 외부호출인 경우 API URL만 확인 (테이블 검증 제외)
|
||||
if (state.connectionType === "external_call") {
|
||||
if (!state.externalCallConfig?.restApiSettings?.apiUrl) {
|
||||
toast.error("API URL을 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
// 외부호출은 테이블 정보 검증 건너뛰기
|
||||
}
|
||||
|
||||
// 중복 체크 (수정 모드가 아닌 경우에만)
|
||||
if (!diagramId) {
|
||||
try {
|
||||
|
|
@ -558,22 +580,62 @@ const DataConnectionDesigner: React.FC<DataConnectionDesignerProps> = ({
|
|||
relationshipName: state.relationshipName,
|
||||
description: state.description,
|
||||
connectionType: state.connectionType,
|
||||
fromConnection: state.fromConnection,
|
||||
toConnection: state.toConnection,
|
||||
fromTable: state.fromTable,
|
||||
toTable: state.toTable,
|
||||
// 외부호출인 경우 테이블 정보는 선택사항
|
||||
fromConnection: state.connectionType === "external_call" ? null : state.fromConnection,
|
||||
toConnection: state.connectionType === "external_call" ? null : state.toConnection,
|
||||
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,
|
||||
// 외부호출 설정 포함
|
||||
externalCallConfig: state.externalCallConfig,
|
||||
// 기존 호환성을 위한 필드들 (첫 번째 액션 그룹의 첫 번째 액션에서 추출)
|
||||
actionType: state.actionGroups[0]?.actions[0]?.actionType || state.actionType || "insert",
|
||||
controlConditions: state.controlConditions,
|
||||
actionConditions: state.actionGroups[0]?.actions[0]?.conditions || state.actionConditions || [],
|
||||
fieldMappings: state.actionGroups[0]?.actions[0]?.fieldMappings || state.fieldMappings || [],
|
||||
actionType:
|
||||
state.connectionType === "external_call"
|
||||
? "external_call"
|
||||
: 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 });
|
||||
|
||||
// 외부호출인 경우 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 전달)
|
||||
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">
|
||||
{/* 좌측 패널 (30%) */}
|
||||
<div className="flex h-[calc(100vh-200px)] min-h-[700px] overflow-hidden">
|
||||
{/* 좌측 패널 (30%) - 항상 표시 */}
|
||||
<div className="flex w-[30%] flex-col border-r bg-white">
|
||||
<LeftPanel state={state} actions={actions} />
|
||||
</div>
|
||||
|
||||
{/* 우측 패널 (70%) */}
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -35,7 +35,10 @@ const ConnectionTypeSelector: React.FC<ConnectionTypeSelectorProps> = ({ selecte
|
|||
<CardContent className="p-4">
|
||||
<RadioGroup
|
||||
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"
|
||||
>
|
||||
{connectionTypes.map((type) => (
|
||||
|
|
|
|||
|
|
@ -30,54 +30,70 @@ const LeftPanel: React.FC<LeftPanelProps> = ({ state, actions }) => {
|
|||
<ConnectionTypeSelector selectedType={state.connectionType} onTypeChange={actions.setConnectionType} />
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
{/* 외부호출이 아닐 때만 매핑과 액션 설정 표시 */}
|
||||
{state.connectionType !== "external_call" && (
|
||||
<>
|
||||
<Separator />
|
||||
|
||||
{/* 매핑 상세 목록 */}
|
||||
{(() => {
|
||||
// 액션 그룹에서 모든 매핑 수집
|
||||
const allMappings = state.actionGroups.flatMap((group) =>
|
||||
group.actions.flatMap((action) => action.fieldMappings || []),
|
||||
);
|
||||
{/* 매핑 상세 목록 */}
|
||||
{(() => {
|
||||
// 액션 그룹에서 모든 매핑 수집
|
||||
const allMappings = state.actionGroups.flatMap((group) =>
|
||||
group.actions.flatMap((action) => action.fieldMappings || []),
|
||||
);
|
||||
|
||||
// 기존 fieldMappings와 병합 (중복 제거)
|
||||
const combinedMappings = [...state.fieldMappings, ...allMappings];
|
||||
const uniqueMappings = combinedMappings.filter(
|
||||
(mapping, index, arr) => arr.findIndex((m) => m.id === mapping.id) === index,
|
||||
);
|
||||
// 기존 fieldMappings와 병합 (중복 제거)
|
||||
const combinedMappings = [...state.fieldMappings, ...allMappings];
|
||||
const uniqueMappings = combinedMappings.filter(
|
||||
(mapping, index, arr) => arr.findIndex((m) => m.id === mapping.id) === index,
|
||||
);
|
||||
|
||||
console.log("🔍 LeftPanel - 매핑 데이터 수집:", {
|
||||
stateFieldMappings: state.fieldMappings,
|
||||
actionGroupMappings: allMappings,
|
||||
combinedMappings: uniqueMappings,
|
||||
});
|
||||
console.log("🔍 LeftPanel - 매핑 데이터 수집:", {
|
||||
stateFieldMappings: state.fieldMappings,
|
||||
actionGroupMappings: allMappings,
|
||||
combinedMappings: uniqueMappings,
|
||||
});
|
||||
|
||||
return (
|
||||
uniqueMappings.length > 0 && (
|
||||
<>
|
||||
<div>
|
||||
<h3 className="text-muted-foreground mb-2 text-sm font-medium">매핑 상세 목록</h3>
|
||||
<MappingDetailList
|
||||
mappings={uniqueMappings}
|
||||
selectedMapping={state.selectedMapping}
|
||||
onSelectMapping={(mappingId) => {
|
||||
// TODO: 선택된 매핑 상태 업데이트
|
||||
}}
|
||||
onUpdateMapping={actions.updateMapping}
|
||||
onDeleteMapping={actions.deleteMapping}
|
||||
/>
|
||||
</div>
|
||||
return (
|
||||
uniqueMappings.length > 0 && (
|
||||
<>
|
||||
<div>
|
||||
<h3 className="text-muted-foreground mb-2 text-sm font-medium">매핑 상세 목록</h3>
|
||||
<MappingDetailList
|
||||
mappings={uniqueMappings}
|
||||
selectedMapping={state.selectedMapping}
|
||||
onSelectMapping={(mappingId) => {
|
||||
// TODO: 선택된 매핑 상태 업데이트
|
||||
}}
|
||||
onUpdateMapping={actions.updateMapping}
|
||||
onDeleteMapping={actions.deleteMapping}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
</>
|
||||
)
|
||||
);
|
||||
})()}
|
||||
<Separator />
|
||||
</>
|
||||
)
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* 액션 설정 요약 */}
|
||||
<div>
|
||||
<h3 className="text-muted-foreground mb-2 text-sm font-medium">액션 설정</h3>
|
||||
<ActionSummaryPanel state={state} />
|
||||
</div>
|
||||
{/* 액션 설정 요약 */}
|
||||
<div>
|
||||
<h3 className="text-muted-foreground mb-2 text-sm font-medium">액션 설정</h3>
|
||||
<ActionSummaryPanel state={state} />
|
||||
</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>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import React, { useEffect } from "react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Globe } from "lucide-react";
|
||||
|
||||
// 타입 import
|
||||
import { RightPanelProps } from "../types/redesigned";
|
||||
|
|
@ -14,6 +16,7 @@ import FieldMappingStep from "./FieldMappingStep";
|
|||
import ControlConditionStep from "./ControlConditionStep";
|
||||
import ActionConfigStep from "./ActionConfigStep";
|
||||
import MultiActionConfigStep from "./MultiActionConfigStep";
|
||||
import ExternalCallPanel from "../../../external-call/ExternalCallPanel";
|
||||
|
||||
/**
|
||||
* 🎯 우측 패널 (70% 너비)
|
||||
|
|
@ -22,6 +25,12 @@ import MultiActionConfigStep from "./MultiActionConfigStep";
|
|||
* - 시각적 매핑 영역
|
||||
*/
|
||||
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[] = [];
|
||||
|
||||
|
|
@ -52,104 +61,225 @@ const RightPanel: React.FC<RightPanelProps> = ({ state, actions }) => {
|
|||
}
|
||||
|
||||
const renderCurrentStep = () => {
|
||||
switch (state.currentStep) {
|
||||
case 1:
|
||||
try {
|
||||
// 외부호출인 경우 단계 무시하고 바로 외부호출 설정 화면 표시
|
||||
console.log("🔍 [RightPanel] renderCurrentStep - connectionType:", state.connectionType);
|
||||
if (state.connectionType === "external_call") {
|
||||
console.log("✅ [RightPanel] 외부호출 화면 렌더링");
|
||||
return (
|
||||
<ConnectionStep
|
||||
connectionType={state.connectionType}
|
||||
fromConnection={state.fromConnection}
|
||||
toConnection={state.toConnection}
|
||||
relationshipName={state.relationshipName}
|
||||
description={state.description}
|
||||
diagramId={state.diagramId} // 🔧 수정 모드 감지용
|
||||
onSelectConnection={actions.selectConnection}
|
||||
onSetRelationshipName={actions.setRelationshipName}
|
||||
onSetDescription={actions.setDescription}
|
||||
onNext={() => actions.goToStep(2)}
|
||||
/>
|
||||
);
|
||||
<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>
|
||||
|
||||
case 2:
|
||||
return (
|
||||
<TableStep
|
||||
fromConnection={state.fromConnection}
|
||||
toConnection={state.toConnection}
|
||||
fromTable={state.fromTable}
|
||||
toTable={state.toTable}
|
||||
onSelectTable={actions.selectTable}
|
||||
onNext={() => actions.goToStep(3)} // 3단계(제어 조건)로
|
||||
onBack={() => actions.goToStep(1)}
|
||||
/>
|
||||
);
|
||||
{/* 관계명 및 설명 입력 */}
|
||||
<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>
|
||||
|
||||
case 3:
|
||||
// 3단계: 제어 조건
|
||||
return (
|
||||
<ControlConditionStep
|
||||
state={state}
|
||||
actions={actions}
|
||||
onBack={() => actions.goToStep(2)}
|
||||
onNext={() => {
|
||||
// 4단계로 넘어가기 전에 컬럼 로드
|
||||
actions.loadColumns();
|
||||
actions.goToStep(4);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
{/* 외부호출 패널 - 공간 최적화 */}
|
||||
<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>
|
||||
|
||||
case 4:
|
||||
// 4단계: 통합된 멀티 액션 설정 (제어 조건 + 액션 설정 + 컬럼 매핑)
|
||||
return (
|
||||
<MultiActionConfigStep
|
||||
fromTable={state.fromTable}
|
||||
toTable={state.toTable}
|
||||
fromConnection={state.fromConnection}
|
||||
toConnection={state.toConnection}
|
||||
fromColumns={state.fromColumns} // 🔧 중앙에서 관리되는 컬럼 정보
|
||||
toColumns={state.toColumns} // 🔧 중앙에서 관리되는 컬럼 정보
|
||||
controlConditions={state.controlConditions}
|
||||
onUpdateControlCondition={actions.updateControlCondition}
|
||||
onDeleteControlCondition={actions.deleteControlCondition}
|
||||
onAddControlCondition={actions.addControlCondition}
|
||||
actionGroups={state.actionGroups}
|
||||
groupsLogicalOperator={state.groupsLogicalOperator}
|
||||
onUpdateActionGroup={actions.updateActionGroup}
|
||||
onDeleteActionGroup={actions.deleteActionGroup}
|
||||
onAddActionGroup={actions.addActionGroup}
|
||||
onAddActionToGroup={actions.addActionToGroup}
|
||||
onUpdateActionInGroup={actions.updateActionInGroup}
|
||||
onDeleteActionFromGroup={actions.deleteActionFromGroup}
|
||||
onSetGroupsLogicalOperator={actions.setGroupsLogicalOperator}
|
||||
fieldMappings={state.fieldMappings}
|
||||
onCreateMapping={actions.createMapping}
|
||||
onDeleteMapping={actions.deleteMapping}
|
||||
onLoadColumns={actions.loadColumns}
|
||||
onNext={() => {
|
||||
// 완료 처리 - 저장 및 상위 컴포넌트 알림
|
||||
actions.saveMappings();
|
||||
}}
|
||||
onBack={() => actions.goToStep(3)}
|
||||
/>
|
||||
{/* 하단 버튼 - 바로 붙여서 고정 */}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
default:
|
||||
return null;
|
||||
// 데이터 저장인 경우에만 단계별 진행
|
||||
switch (state.currentStep) {
|
||||
case 1:
|
||||
return (
|
||||
<ConnectionStep
|
||||
connectionType={state.connectionType}
|
||||
fromConnection={state.fromConnection}
|
||||
toConnection={state.toConnection}
|
||||
relationshipName={state.relationshipName}
|
||||
description={state.description}
|
||||
diagramId={state.diagramId} // 🔧 수정 모드 감지용
|
||||
onSelectConnection={actions.selectConnection}
|
||||
onSetRelationshipName={actions.setRelationshipName}
|
||||
onSetDescription={actions.setDescription}
|
||||
onNext={() => actions.goToStep(2)}
|
||||
/>
|
||||
);
|
||||
|
||||
case 2:
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<TableStep
|
||||
fromConnection={state.fromConnection}
|
||||
toConnection={state.toConnection}
|
||||
fromTable={state.fromTable}
|
||||
toTable={state.toTable}
|
||||
onSelectTable={actions.selectTable}
|
||||
onNext={() => actions.goToStep(3)} // 3단계(제어 조건)로
|
||||
onBack={() => actions.goToStep(1)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 3:
|
||||
// 데이터 저장인 경우 제어 조건 단계
|
||||
return (
|
||||
<ControlConditionStep
|
||||
state={state}
|
||||
actions={actions}
|
||||
onBack={() => actions.goToStep(2)}
|
||||
onNext={() => {
|
||||
// 4단계로 넘어가기 전에 컬럼 로드
|
||||
actions.loadColumns();
|
||||
actions.goToStep(4);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
case 4:
|
||||
// 외부호출인 경우 4단계 없음
|
||||
if (state.connectionType === "external_call") {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 4단계: 통합된 멀티 액션 설정 (제어 조건 + 액션 설정 + 컬럼 매핑)
|
||||
return (
|
||||
<MultiActionConfigStep
|
||||
fromTable={state.fromTable}
|
||||
toTable={state.toTable}
|
||||
fromConnection={state.fromConnection}
|
||||
toConnection={state.toConnection}
|
||||
fromColumns={state.fromColumns} // 🔧 중앙에서 관리되는 컬럼 정보
|
||||
toColumns={state.toColumns} // 🔧 중앙에서 관리되는 컬럼 정보
|
||||
controlConditions={state.controlConditions}
|
||||
onUpdateControlCondition={actions.updateControlCondition}
|
||||
onDeleteControlCondition={actions.deleteControlCondition}
|
||||
onAddControlCondition={actions.addControlCondition}
|
||||
actionGroups={state.actionGroups}
|
||||
groupsLogicalOperator={state.groupsLogicalOperator}
|
||||
onUpdateActionGroup={actions.updateActionGroup}
|
||||
onDeleteActionGroup={actions.deleteActionGroup}
|
||||
onAddActionGroup={actions.addActionGroup}
|
||||
onAddActionToGroup={actions.addActionToGroup}
|
||||
onUpdateActionInGroup={actions.updateActionInGroup}
|
||||
onDeleteActionFromGroup={actions.deleteActionFromGroup}
|
||||
onSetGroupsLogicalOperator={actions.setGroupsLogicalOperator}
|
||||
fieldMappings={state.fieldMappings}
|
||||
onCreateMapping={actions.createMapping}
|
||||
onDeleteMapping={actions.deleteMapping}
|
||||
onLoadColumns={actions.loadColumns}
|
||||
onNext={() => {
|
||||
// 완료 처리 - 저장 및 상위 컴포넌트 알림
|
||||
actions.saveMappings();
|
||||
}}
|
||||
onBack={() => actions.goToStep(3)}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ [RightPanel] renderCurrentStep 에러:", error);
|
||||
return <div>renderCurrentStep 에러: {String(error)}</div>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* 단계 진행 표시 */}
|
||||
<div className="bg-card/50 border-b p-3">
|
||||
<StepProgress currentStep={state.currentStep} completedSteps={completedSteps} onStepClick={actions.goToStep} />
|
||||
</div>
|
||||
try {
|
||||
console.log("🎯 [RightPanel] return 시작 - connectionType:", state.connectionType);
|
||||
|
||||
{/* 현재 단계 컨텐츠 */}
|
||||
<div className="min-h-0 flex-1 p-3">
|
||||
<Card className="flex h-full flex-col overflow-hidden">{renderCurrentStep()}</Card>
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* 단계 진행 표시 - 외부호출이 아닐 때만 */}
|
||||
{state.connectionType !== "external_call" && (
|
||||
<div className="bg-card/50 border-b p-3">
|
||||
<StepProgress
|
||||
currentStep={state.currentStep}
|
||||
completedSteps={completedSteps}
|
||||
onStepClick={actions.goToStep}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 현재 단계 컨텐츠 */}
|
||||
<div className="min-h-0 flex-1 p-3">
|
||||
{(() => {
|
||||
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>
|
||||
);
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("❌ [RightPanel] 렌더링 에러:", error);
|
||||
return <div>렌더링 에러 발생: {String(error)}</div>;
|
||||
}
|
||||
};
|
||||
|
||||
export default RightPanel;
|
||||
|
|
|
|||
|
|
@ -111,6 +111,22 @@ export interface DataConnectionState {
|
|||
actionGroups: ActionGroup[];
|
||||
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)
|
||||
actionType?: "insert" | "update" | "delete" | "upsert";
|
||||
actionConditions?: any[]; // 각 액션의 대상 레코드 조건
|
||||
|
|
@ -154,6 +170,9 @@ export interface DataConnectionActions {
|
|||
updateControlCondition: (index: number, condition: any) => void;
|
||||
deleteControlCondition: (index: number) => void;
|
||||
|
||||
// 외부호출 설정 관리
|
||||
updateExternalCallConfig: (config: any) => void;
|
||||
|
||||
// 액션 그룹 관리 (멀티 액션)
|
||||
addActionGroup: () => void;
|
||||
updateActionGroup: (groupId: string, updates: Partial<ActionGroup>) => void;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -63,12 +63,17 @@ export const loadDataflowRelationship = async (diagramId: number) => {
|
|||
// 기존 구조와 redesigned 구조 모두 지원
|
||||
let relationshipsData;
|
||||
|
||||
// Case 1: Redesigned UI 구조 (단일 관계 객체)
|
||||
if (diagram.relationships.relationships && !Array.isArray(diagram.relationships.relationships)) {
|
||||
// Case 1: 현재 구조 - 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;
|
||||
console.log("✅ Redesigned 구조 감지:", relationshipsData);
|
||||
}
|
||||
// Case 2: 기존 구조 (관계 배열) - 첫 번째 관계만 로드
|
||||
// Case 3: 기존 구조 (관계 배열) - 첫 번째 관계만 로드
|
||||
else if (diagram.relationships.relationships && Array.isArray(diagram.relationships.relationships)) {
|
||||
const firstRelation = diagram.relationships.relationships[0];
|
||||
if (!firstRelation) {
|
||||
|
|
@ -151,6 +156,8 @@ export const loadDataflowRelationship = async (diagramId: number) => {
|
|||
fieldMappings: relationshipsData.fieldMappings || [],
|
||||
// 🔧 멀티 액션 그룹 데이터 포함
|
||||
actionGroups: relationshipsData.actionGroups,
|
||||
// 🔧 외부호출 설정 데이터 포함
|
||||
externalCallConfig: relationshipsData.externalCallConfig,
|
||||
};
|
||||
|
||||
console.log("✨ 변환된 로드 데이터:", loadedData);
|
||||
|
|
@ -194,6 +201,8 @@ export const saveDataflowRelationship = async (data: any, diagramId?: number) =>
|
|||
description: data.description,
|
||||
// 🔧 멀티 액션 그룹 데이터 추가
|
||||
actionGroups: data.actionGroups,
|
||||
// 🔧 외부호출 설정 데이터 추가
|
||||
externalCallConfig: data.externalCallConfig,
|
||||
},
|
||||
category: {
|
||||
type: "data-connection",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
id?: number;
|
||||
config_name: string;
|
||||
call_type: string;
|
||||
api_type?: string;
|
||||
config_data: Record<string, unknown>;
|
||||
config_data: any;
|
||||
description?: string;
|
||||
company_code?: string;
|
||||
is_active?: string;
|
||||
created_date?: string;
|
||||
created_by?: string;
|
||||
updated_date?: string;
|
||||
updated_by?: string;
|
||||
}
|
||||
|
||||
export interface ExternalCallConfigFilter {
|
||||
company_code?: string;
|
||||
is_active?: string;
|
||||
call_type?: string;
|
||||
api_type?: string;
|
||||
is_active?: string;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export interface ExternalCallConfigTestResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 외부 호출 설정 관리 API 클라이언트
|
||||
* 외부호출 설정 API 클래스
|
||||
*/
|
||||
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 {
|
||||
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);
|
||||
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);
|
||||
|
||||
const response = await apiClient.get(`/external-call-configs?${params.toString()}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error("외부 호출 설정 목록 조회 실패:", error);
|
||||
throw error;
|
||||
console.error("외부호출 설정 목록 조회 실패:", 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 {
|
||||
const response = await apiClient.get(`${this.BASE_URL}/${id}`);
|
||||
const response = await apiClient.get(`/external-call-configs/${id}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error(`외부 호출 설정 조회 실패 (ID: ${id}):`, error);
|
||||
throw error;
|
||||
console.error("외부호출 설정 상세 조회 실패:", error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 외부 호출 설정 생성
|
||||
* 외부호출 설정 생성
|
||||
*/
|
||||
static async createConfig(
|
||||
config: Omit<ExternalCallConfig, "id" | "created_date" | "updated_date">,
|
||||
): Promise<ApiResponse<ExternalCallConfig>> {
|
||||
static async createConfig(data: Omit<ExternalCallConfig, "id">): Promise<{
|
||||
success: boolean;
|
||||
data?: ExternalCallConfig;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const response = await apiClient.post(this.BASE_URL, config);
|
||||
const response = await apiClient.post("/external-call-configs", data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error("외부 호출 설정 생성 실패:", error);
|
||||
throw error;
|
||||
console.error("외부호출 설정 생성 실패:", 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 {
|
||||
const response = await apiClient.put(`${this.BASE_URL}/${id}`, config);
|
||||
const response = await apiClient.put(`/external-call-configs/${id}`, data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error(`외부 호출 설정 수정 실패 (ID: ${id}):`, error);
|
||||
throw error;
|
||||
console.error("외부호출 설정 수정 실패:", 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 {
|
||||
const response = await apiClient.delete(`${this.BASE_URL}/${id}`);
|
||||
const response = await apiClient.delete(`/external-call-configs/${id}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error(`외부 호출 설정 삭제 실패 (ID: ${id}):`, error);
|
||||
throw error;
|
||||
console.error("외부호출 설정 삭제 실패:", 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 {
|
||||
const response = await apiClient.post(`${this.BASE_URL}/${id}/test`);
|
||||
const response = await apiClient.post(`/external-call-configs/${id}/test`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error(`외부 호출 설정 테스트 실패 (ID: ${id}):`, error);
|
||||
throw error;
|
||||
console.error("외부호출 설정 테스트 실패:", 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: "비활성" },
|
||||
];
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"dev": "next dev --turbopack -p 9771",
|
||||
"build": "next build",
|
||||
"build:no-lint": "DISABLE_ESLINT_PLUGIN=true next build",
|
||||
"start": "next start",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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[];
|
||||
};
|
||||
}
|
||||
|
|
@ -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 외부호출 기능을 완성할 수 있습니다.
|
||||
Loading…
Reference in New Issue