외부호출 기능(rest API)
This commit is contained in:
322
frontend/components/dataflow/external-call/ExternalCallPanel.tsx
Normal file
322
frontend/components/dataflow/external-call/ExternalCallPanel.tsx
Normal file
@@ -0,0 +1,322 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useCallback, useEffect } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Globe, Settings, TestTube, History, Info } from "lucide-react";
|
||||
|
||||
// 타입 import
|
||||
import {
|
||||
ExternalCallConfig,
|
||||
ExternalCallPanelProps,
|
||||
RestApiSettings as RestApiSettingsType,
|
||||
ApiTestResult,
|
||||
} from "@/types/external-call/ExternalCallTypes";
|
||||
|
||||
// 하위 컴포넌트 import
|
||||
import RestApiSettings from "./RestApiSettings";
|
||||
import ExternalCallTestPanel from "./ExternalCallTestPanel";
|
||||
|
||||
/**
|
||||
* 🌐 외부호출 메인 패널 컴포넌트
|
||||
*
|
||||
* 데이터 저장 기능과 완전히 분리된 독립적인 외부호출 전용 패널
|
||||
* REST API 설정, 테스트, 실행 이력 등을 통합 관리
|
||||
*/
|
||||
const ExternalCallPanel: React.FC<ExternalCallPanelProps> = ({
|
||||
relationshipId,
|
||||
onSettingsChange,
|
||||
initialSettings,
|
||||
readonly = false,
|
||||
}) => {
|
||||
console.log("🌐 [ExternalCallPanel] Component mounted with props:", {
|
||||
relationshipId,
|
||||
initialSettings,
|
||||
readonly,
|
||||
});
|
||||
// 상태 관리
|
||||
const [config, setConfig] = useState<ExternalCallConfig>(
|
||||
() =>
|
||||
initialSettings || {
|
||||
callType: "rest-api",
|
||||
restApiSettings: {
|
||||
apiUrl: "",
|
||||
httpMethod: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
bodyTemplate: `{
|
||||
"message": "데이터가 업데이트되었습니다",
|
||||
"data": {{sourceData}},
|
||||
"timestamp": "{{timestamp}}",
|
||||
"relationshipId": "{{relationshipId}}"
|
||||
}`,
|
||||
authentication: {
|
||||
type: "none",
|
||||
},
|
||||
timeout: 30000, // 30초
|
||||
retryCount: 3,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<string>("settings");
|
||||
const [lastTestResult, setLastTestResult] = useState<ApiTestResult | null>(null);
|
||||
const [isConfigValid, setIsConfigValid] = useState<boolean>(false);
|
||||
|
||||
// 설정 변경 핸들러
|
||||
const handleRestApiSettingsChange = useCallback(
|
||||
(newSettings: RestApiSettingsType) => {
|
||||
const updatedConfig: ExternalCallConfig = {
|
||||
...config,
|
||||
restApiSettings: newSettings,
|
||||
metadata: {
|
||||
...config.metadata,
|
||||
updatedAt: new Date().toISOString(),
|
||||
version: "1.0",
|
||||
},
|
||||
};
|
||||
|
||||
setConfig(updatedConfig);
|
||||
onSettingsChange(updatedConfig);
|
||||
},
|
||||
[config, onSettingsChange],
|
||||
);
|
||||
|
||||
// 테스트 결과 핸들러
|
||||
const handleTestResult = useCallback((result: ApiTestResult) => {
|
||||
setLastTestResult(result);
|
||||
|
||||
// 테스트 탭에 머물러서 응답 정보를 바로 확인할 수 있도록 함
|
||||
// (이전에는 성공 시 자동으로 history 탭으로 이동했음)
|
||||
}, []);
|
||||
|
||||
// 설정 유효성 검사
|
||||
const validateConfig = useCallback(() => {
|
||||
const { restApiSettings } = config;
|
||||
|
||||
// HTTP 메서드에 따라 바디 필요 여부 결정
|
||||
const methodNeedsBody = !["GET", "HEAD", "DELETE"].includes(restApiSettings.httpMethod?.toUpperCase());
|
||||
|
||||
const isValid = !!(
|
||||
restApiSettings.apiUrl &&
|
||||
restApiSettings.apiUrl.startsWith("http") &&
|
||||
restApiSettings.httpMethod &&
|
||||
(methodNeedsBody ? restApiSettings.bodyTemplate : true) // GET/HEAD/DELETE는 바디 불필요
|
||||
);
|
||||
|
||||
setIsConfigValid(isValid);
|
||||
return isValid;
|
||||
}, [config]);
|
||||
|
||||
// 설정 변경 시 유효성 검사 실행
|
||||
useEffect(() => {
|
||||
validateConfig();
|
||||
}, [validateConfig]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full max-h-full flex-col space-y-2">
|
||||
{/* 헤더 */}
|
||||
<Card>
|
||||
<CardHeader className="pt-3 pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="h-5 w-5 text-blue-500" />
|
||||
<CardTitle className="text-lg">외부 호출 설정</CardTitle>
|
||||
<Badge variant={isConfigValid ? "default" : "secondary"}>
|
||||
{isConfigValid ? "설정 완료" : "설정 필요"}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-muted-foreground text-sm">
|
||||
관계 실행 시 외부 API를 호출하여 데이터를 전송하거나 알림을 보낼 수 있습니다.
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
{/* 메인 탭 컨텐츠 */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex w-full flex-1 flex-col overflow-hidden">
|
||||
<TabsList className="grid w-full grid-cols-4">
|
||||
<TabsTrigger value="settings" className="flex items-center gap-2">
|
||||
<Settings className="h-4 w-4" />
|
||||
설정
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="test" className="flex items-center gap-2">
|
||||
<TestTube className="h-4 w-4" />
|
||||
테스트
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="history" className="flex items-center gap-2">
|
||||
<History className="h-4 w-4" />
|
||||
이력
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="info" className="flex items-center gap-2">
|
||||
<Info className="h-4 w-4" />
|
||||
정보
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 설정 탭 */}
|
||||
<TabsContent value="settings" className="flex-1 space-y-2 overflow-y-auto">
|
||||
<RestApiSettings
|
||||
settings={config.restApiSettings}
|
||||
onSettingsChange={handleRestApiSettingsChange}
|
||||
readonly={readonly}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{/* 테스트 탭 */}
|
||||
<TabsContent value="test" className="flex-1 space-y-4 overflow-y-auto">
|
||||
{isConfigValid ? (
|
||||
<ExternalCallTestPanel
|
||||
settings={config.restApiSettings}
|
||||
context={{
|
||||
relationshipId,
|
||||
diagramId: "test-diagram",
|
||||
userId: "current-user",
|
||||
executionId: "test-execution",
|
||||
sourceData: { test: "data" },
|
||||
timestamp: new Date().toISOString(),
|
||||
}}
|
||||
onTestResult={handleTestResult}
|
||||
disabled={readonly}
|
||||
/>
|
||||
) : (
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription>API 테스트를 실행하려면 먼저 설정 탭에서 필수 정보를 입력해주세요.</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* 이력 탭 */}
|
||||
<TabsContent value="history" className="flex-1 space-y-4 overflow-y-auto">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">실행 이력</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{lastTestResult ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">최근 테스트 결과</span>
|
||||
<Badge variant={lastTestResult.success ? "default" : "destructive"}>
|
||||
{lastTestResult.success ? "성공" : "실패"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">상태 코드:</span>
|
||||
<span className="ml-2 font-mono">{lastTestResult.statusCode || "N/A"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">응답 시간:</span>
|
||||
<span className="ml-2 font-mono">{lastTestResult.responseTime}ms</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{lastTestResult.error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription className="text-sm">{lastTestResult.error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{lastTestResult.responseData && (
|
||||
<div>
|
||||
<span className="text-muted-foreground text-sm">응답 데이터:</span>
|
||||
<pre className="bg-muted mt-1 max-h-32 overflow-auto rounded p-2 text-xs">
|
||||
{JSON.stringify(lastTestResult.responseData, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground py-8 text-center">
|
||||
<TestTube className="mx-auto mb-2 h-8 w-8 opacity-50" />
|
||||
<p>아직 실행 이력이 없습니다.</p>
|
||||
<p className="text-sm">테스트 탭에서 API 호출을 테스트해보세요.</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* 정보 탭 */}
|
||||
<TabsContent value="info" className="flex-1 space-y-4 overflow-y-auto">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">템플릿 변수 가이드</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="text-muted-foreground text-sm">요청 바디에서 사용할 수 있는 템플릿 변수들입니다:</div>
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<code className="bg-muted rounded px-2 py-1">{"{{sourceData}}"}</code>
|
||||
<span className="text-muted-foreground">소스 노드의 전체 데이터</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<code className="bg-muted rounded px-2 py-1">{"{{timestamp}}"}</code>
|
||||
<span className="text-muted-foreground">현재 타임스탬프</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<code className="bg-muted rounded px-2 py-1">{"{{relationshipId}}"}</code>
|
||||
<span className="text-muted-foreground">관계 ID</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<code className="bg-muted rounded px-2 py-1">{"{{userId}}"}</code>
|
||||
<span className="text-muted-foreground">현재 사용자 ID</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<code className="bg-muted rounded px-2 py-1">{"{{executionId}}"}</code>
|
||||
<span className="text-muted-foreground">실행 ID</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription className="text-sm">
|
||||
템플릿 변수는 실제 실행 시 해당 값으로 자동 치환됩니다. JSON 형식의 데이터는 따옴표 없이 사용하세요.
|
||||
(예: {"{{sourceData}}"})
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">설정 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">관계 ID:</span>
|
||||
<code className="bg-muted rounded px-2 py-1 text-xs">{relationshipId}</code>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">호출 타입:</span>
|
||||
<Badge variant="outline">{config.callType.toUpperCase()}</Badge>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">설정 상태:</span>
|
||||
<Badge variant={isConfigValid ? "default" : "secondary"}>{isConfigValid ? "완료" : "미완료"}</Badge>
|
||||
</div>
|
||||
{config.metadata?.updatedAt && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">마지막 수정:</span>
|
||||
<span className="text-xs">{new Date(config.metadata.updatedAt).toLocaleString()}</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExternalCallPanel;
|
||||
@@ -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;
|
||||
659
frontend/components/dataflow/external-call/RestApiSettings.tsx
Normal file
659
frontend/components/dataflow/external-call/RestApiSettings.tsx
Normal file
@@ -0,0 +1,659 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useCallback, useEffect } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import {
|
||||
Globe,
|
||||
Key,
|
||||
Clock,
|
||||
RefreshCw,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Plus,
|
||||
Trash2,
|
||||
Copy,
|
||||
Eye,
|
||||
EyeOff,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
} from "lucide-react";
|
||||
|
||||
// 타입 import
|
||||
import { RestApiSettings as RestApiSettingsType, RestApiSettingsProps } from "@/types/external-call/ExternalCallTypes";
|
||||
import {
|
||||
HttpMethod,
|
||||
AuthenticationType,
|
||||
COMMON_HEADER_PRESETS,
|
||||
JSON_BODY_TEMPLATES,
|
||||
DEFAULT_RETRY_POLICY,
|
||||
DEFAULT_TIMEOUT_CONFIG,
|
||||
} from "@/types/external-call/RestApiTypes";
|
||||
|
||||
/**
|
||||
* 🔧 REST API 전용 설정 컴포넌트
|
||||
*
|
||||
* URL, HTTP 메서드, 헤더, 인증, 바디 템플릿 등
|
||||
* REST API 호출에 필요한 모든 설정을 관리
|
||||
*/
|
||||
const RestApiSettings: React.FC<RestApiSettingsProps> = ({ settings, onSettingsChange, readonly = false }) => {
|
||||
// 상태 관리
|
||||
const [activeTab, setActiveTab] = useState<string>("basic");
|
||||
const [showPassword, setShowPassword] = useState<boolean>(false);
|
||||
const [isAdvancedOpen, setIsAdvancedOpen] = useState<boolean>(false);
|
||||
const [validationErrors, setValidationErrors] = useState<string[]>([]);
|
||||
const [newHeaderKey, setNewHeaderKey] = useState<string>("");
|
||||
const [newHeaderValue, setNewHeaderValue] = useState<string>("");
|
||||
|
||||
// URL 변경 핸들러
|
||||
const handleUrlChange = useCallback(
|
||||
(url: string) => {
|
||||
onSettingsChange({
|
||||
...settings,
|
||||
apiUrl: url,
|
||||
});
|
||||
},
|
||||
[settings, onSettingsChange],
|
||||
);
|
||||
|
||||
// HTTP 메서드 변경 핸들러
|
||||
const handleMethodChange = useCallback(
|
||||
(method: HttpMethod) => {
|
||||
onSettingsChange({
|
||||
...settings,
|
||||
httpMethod: method,
|
||||
});
|
||||
},
|
||||
[settings, onSettingsChange],
|
||||
);
|
||||
|
||||
// 헤더 추가 핸들러
|
||||
const handleAddHeader = useCallback(() => {
|
||||
if (newHeaderKey && newHeaderValue) {
|
||||
onSettingsChange({
|
||||
...settings,
|
||||
headers: {
|
||||
...settings.headers,
|
||||
[newHeaderKey]: newHeaderValue,
|
||||
},
|
||||
});
|
||||
setNewHeaderKey("");
|
||||
setNewHeaderValue("");
|
||||
}
|
||||
}, [settings, onSettingsChange, newHeaderKey, newHeaderValue]);
|
||||
|
||||
// 헤더 삭제 핸들러
|
||||
const handleRemoveHeader = useCallback(
|
||||
(key: string) => {
|
||||
const newHeaders = { ...settings.headers };
|
||||
delete newHeaders[key];
|
||||
onSettingsChange({
|
||||
...settings,
|
||||
headers: newHeaders,
|
||||
});
|
||||
},
|
||||
[settings, onSettingsChange],
|
||||
);
|
||||
|
||||
// 헤더 프리셋 적용 핸들러
|
||||
const handleApplyHeaderPreset = useCallback(
|
||||
(presetName: string) => {
|
||||
const preset = COMMON_HEADER_PRESETS.find((p) => p.name === presetName);
|
||||
if (preset) {
|
||||
onSettingsChange({
|
||||
...settings,
|
||||
headers: {
|
||||
...settings.headers,
|
||||
...preset.headers,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
[settings, onSettingsChange],
|
||||
);
|
||||
|
||||
// 바디 템플릿 변경 핸들러
|
||||
const handleBodyTemplateChange = useCallback(
|
||||
(template: string) => {
|
||||
onSettingsChange({
|
||||
...settings,
|
||||
bodyTemplate: template,
|
||||
});
|
||||
},
|
||||
[settings, onSettingsChange],
|
||||
);
|
||||
|
||||
// 바디 템플릿 프리셋 적용 핸들러
|
||||
const handleApplyBodyPreset = useCallback(
|
||||
(presetKey: string) => {
|
||||
const preset = JSON_BODY_TEMPLATES[presetKey as keyof typeof JSON_BODY_TEMPLATES];
|
||||
if (preset) {
|
||||
onSettingsChange({
|
||||
...settings,
|
||||
bodyTemplate: preset.template,
|
||||
});
|
||||
}
|
||||
},
|
||||
[settings, onSettingsChange],
|
||||
);
|
||||
|
||||
// 인증 설정 변경 핸들러
|
||||
const handleAuthChange = useCallback(
|
||||
(auth: Partial<AuthenticationType>) => {
|
||||
onSettingsChange({
|
||||
...settings,
|
||||
authentication: {
|
||||
...settings.authentication,
|
||||
...auth,
|
||||
} as AuthenticationType,
|
||||
});
|
||||
},
|
||||
[settings, onSettingsChange],
|
||||
);
|
||||
|
||||
// 타임아웃 변경 핸들러 (초 단위를 밀리초로 변환)
|
||||
const handleTimeoutChange = useCallback(
|
||||
(timeoutInSeconds: number) => {
|
||||
onSettingsChange({
|
||||
...settings,
|
||||
timeout: timeoutInSeconds * 1000, // 초를 밀리초로 변환
|
||||
});
|
||||
},
|
||||
[settings, onSettingsChange],
|
||||
);
|
||||
|
||||
// 재시도 횟수 변경 핸들러
|
||||
const handleRetryCountChange = useCallback(
|
||||
(retryCount: number) => {
|
||||
onSettingsChange({
|
||||
...settings,
|
||||
retryCount,
|
||||
});
|
||||
},
|
||||
[settings, onSettingsChange],
|
||||
);
|
||||
|
||||
// 설정 유효성 검사
|
||||
const validateSettings = useCallback(() => {
|
||||
const errors: string[] = [];
|
||||
|
||||
// URL 검증
|
||||
if (!settings.apiUrl) {
|
||||
errors.push("API URL은 필수입니다.");
|
||||
} else if (!settings.apiUrl.startsWith("http")) {
|
||||
errors.push("API URL은 http:// 또는 https://로 시작해야 합니다.");
|
||||
}
|
||||
|
||||
// 바디 템플릿 JSON 검증 (POST/PUT/PATCH 메서드인 경우)
|
||||
if (["POST", "PUT", "PATCH"].includes(settings.httpMethod) && settings.bodyTemplate) {
|
||||
try {
|
||||
// 템플릿 변수를 임시 값으로 치환하여 JSON 유효성 검사
|
||||
const testTemplate = settings.bodyTemplate.replace(/\{\{[^}]+\}\}/g, '"test_value"');
|
||||
JSON.parse(testTemplate);
|
||||
} catch {
|
||||
errors.push("요청 바디 템플릿이 유효한 JSON 형식이 아닙니다.");
|
||||
}
|
||||
}
|
||||
|
||||
// 인증 설정 검증
|
||||
if (settings.authentication?.type === "bearer" && !settings.authentication.token) {
|
||||
errors.push("Bearer 토큰이 필요합니다.");
|
||||
}
|
||||
if (
|
||||
settings.authentication?.type === "basic" &&
|
||||
(!settings.authentication.username || !settings.authentication.password)
|
||||
) {
|
||||
errors.push("Basic 인증에는 사용자명과 비밀번호가 필요합니다.");
|
||||
}
|
||||
if (settings.authentication?.type === "api-key" && !settings.authentication.apiKey) {
|
||||
errors.push("API 키가 필요합니다.");
|
||||
}
|
||||
|
||||
setValidationErrors(errors);
|
||||
return errors.length === 0;
|
||||
}, [settings]);
|
||||
|
||||
// 설정 변경 시 유효성 검사 실행
|
||||
useEffect(() => {
|
||||
validateSettings();
|
||||
}, [validateSettings]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 유효성 검사 오류 표시 */}
|
||||
{validationErrors.length > 0 && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
<div className="space-y-1">
|
||||
{validationErrors.map((error, index) => (
|
||||
<div key={index} className="text-sm">
|
||||
• {error}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-4">
|
||||
<TabsTrigger value="basic">기본 설정</TabsTrigger>
|
||||
<TabsTrigger value="headers">헤더</TabsTrigger>
|
||||
<TabsTrigger value="body">요청 바디</TabsTrigger>
|
||||
<TabsTrigger value="auth">인증</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 기본 설정 탭 */}
|
||||
<TabsContent value="basic" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Globe className="h-4 w-4" />
|
||||
기본 설정
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* API URL */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="apiUrl">API URL *</Label>
|
||||
<Input
|
||||
id="apiUrl"
|
||||
type="url"
|
||||
placeholder="https://api.example.com/webhook"
|
||||
value={settings.apiUrl}
|
||||
onChange={(e) => handleUrlChange(e.target.value)}
|
||||
disabled={readonly}
|
||||
className={validationErrors.some((e) => e.includes("URL")) ? "border-red-500" : ""}
|
||||
/>
|
||||
<div className="text-muted-foreground text-xs">호출할 API의 전체 URL을 입력하세요.</div>
|
||||
</div>
|
||||
|
||||
{/* HTTP 메서드 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="httpMethod">HTTP 메서드</Label>
|
||||
<Select value={settings.httpMethod} onValueChange={handleMethodChange} disabled={readonly}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="GET">GET</SelectItem>
|
||||
<SelectItem value="POST">POST</SelectItem>
|
||||
<SelectItem value="PUT">PUT</SelectItem>
|
||||
<SelectItem value="DELETE">DELETE</SelectItem>
|
||||
<SelectItem value="PATCH">PATCH</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 고급 설정 (접을 수 있는 섹션) */}
|
||||
<Collapsible open={isAdvancedOpen} onOpenChange={setIsAdvancedOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="ghost" className="h-auto w-full justify-between p-0">
|
||||
<span className="flex items-center gap-2">
|
||||
<Clock className="h-4 w-4" />
|
||||
고급 설정
|
||||
</span>
|
||||
{isAdvancedOpen ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-4 space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* 타임아웃 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="timeout">타임아웃 (초)</Label>
|
||||
<Input
|
||||
id="timeout"
|
||||
type="number"
|
||||
min="1"
|
||||
max="300"
|
||||
value={Math.round((settings.timeout || DEFAULT_TIMEOUT_CONFIG.request) / 1000)}
|
||||
onChange={(e) => handleTimeoutChange(parseInt(e.target.value))}
|
||||
disabled={readonly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 재시도 횟수 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="retryCount">재시도 횟수</Label>
|
||||
<Input
|
||||
id="retryCount"
|
||||
type="number"
|
||||
min="0"
|
||||
max="10"
|
||||
value={settings.retryCount || DEFAULT_RETRY_POLICY.maxRetries}
|
||||
onChange={(e) => handleRetryCountChange(parseInt(e.target.value))}
|
||||
disabled={readonly}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* 헤더 탭 */}
|
||||
<TabsContent value="headers" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base">HTTP 헤더</CardTitle>
|
||||
<div className="flex gap-2">
|
||||
{COMMON_HEADER_PRESETS.map((preset) => (
|
||||
<Button
|
||||
key={preset.name}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleApplyHeaderPreset(preset.name)}
|
||||
disabled={readonly}
|
||||
className="text-xs"
|
||||
>
|
||||
{preset.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* 기존 헤더 목록 */}
|
||||
<div className="space-y-2">
|
||||
{Object.entries(settings.headers).map(([key, value]) => (
|
||||
<div key={key} className="bg-muted flex items-center gap-2 rounded p-2">
|
||||
<div className="grid flex-1 grid-cols-2 gap-2">
|
||||
<Input value={key} disabled className="bg-background" />
|
||||
<Input value={value} disabled className="bg-background" />
|
||||
</div>
|
||||
{!readonly && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRemoveHeader(key)}
|
||||
className="text-red-500 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 새 헤더 추가 */}
|
||||
{!readonly && (
|
||||
<div className="space-y-2">
|
||||
<Label>새 헤더 추가</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="헤더명 (예: X-API-Key)"
|
||||
value={newHeaderKey}
|
||||
onChange={(e) => setNewHeaderKey(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
placeholder="헤더값"
|
||||
value={newHeaderValue}
|
||||
onChange={(e) => setNewHeaderValue(e.target.value)}
|
||||
/>
|
||||
<Button onClick={handleAddHeader} disabled={!newHeaderKey || !newHeaderValue}>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* 요청 바디 탭 */}
|
||||
<TabsContent value="body" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base">요청 바디 템플릿</CardTitle>
|
||||
<div className="flex gap-2">
|
||||
{Object.entries(JSON_BODY_TEMPLATES).map(([key, template]) => (
|
||||
<Button
|
||||
key={key}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleApplyBodyPreset(key)}
|
||||
disabled={readonly}
|
||||
className="text-xs"
|
||||
>
|
||||
{template.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{["POST", "PUT", "PATCH"].includes(settings.httpMethod) ? (
|
||||
<>
|
||||
<Textarea
|
||||
placeholder="JSON 템플릿을 입력하세요..."
|
||||
value={settings.bodyTemplate}
|
||||
onChange={(e) => handleBodyTemplateChange(e.target.value)}
|
||||
disabled={readonly}
|
||||
className="min-h-[200px] font-mono text-sm"
|
||||
/>
|
||||
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription className="text-sm">
|
||||
템플릿 변수를 사용할 수 있습니다: {"{{sourceData}}"}, {"{{timestamp}}"}, {"{{relationshipId}}"} 등
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</>
|
||||
) : (
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{settings.httpMethod} 메서드는 요청 바디를 사용하지 않습니다.</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* 인증 탭 */}
|
||||
<TabsContent value="auth" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Key className="h-4 w-4" />
|
||||
인증 설정
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* 인증 타입 선택 */}
|
||||
<div className="space-y-2">
|
||||
<Label>인증 방식</Label>
|
||||
<Select
|
||||
value={settings.authentication?.type || "none"}
|
||||
onValueChange={(type) => handleAuthChange({ type: type as AuthenticationType["type"] })}
|
||||
disabled={readonly}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">인증 없음</SelectItem>
|
||||
<SelectItem value="bearer">Bearer Token</SelectItem>
|
||||
<SelectItem value="basic">Basic Authentication</SelectItem>
|
||||
<SelectItem value="api-key">API Key</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 인증 타입별 설정 */}
|
||||
{settings.authentication?.type === "bearer" && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bearerToken">Bearer Token *</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="bearerToken"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="토큰을 입력하세요"
|
||||
value={settings.authentication.token || ""}
|
||||
onChange={(e) => handleAuthChange({ token: e.target.value })}
|
||||
disabled={readonly}
|
||||
className="pr-10"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute top-0 right-0 h-full px-3"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{settings.authentication?.type === "basic" && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username">사용자명 *</Label>
|
||||
<Input
|
||||
id="username"
|
||||
placeholder="사용자명"
|
||||
value={settings.authentication.username || ""}
|
||||
onChange={(e) => handleAuthChange({ username: e.target.value })}
|
||||
disabled={readonly}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">비밀번호 *</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="비밀번호"
|
||||
value={settings.authentication.password || ""}
|
||||
onChange={(e) => handleAuthChange({ password: e.target.value })}
|
||||
disabled={readonly}
|
||||
className="pr-10"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute top-0 right-0 h-full px-3"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{settings.authentication?.type === "api-key" && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="apiKey">API Key *</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="apiKey"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="API 키를 입력하세요"
|
||||
value={settings.authentication.apiKey || ""}
|
||||
onChange={(e) => handleAuthChange({ apiKey: e.target.value })}
|
||||
disabled={readonly}
|
||||
className="pr-10"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute top-0 right-0 h-full px-3"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>위치</Label>
|
||||
<Select
|
||||
value={settings.authentication.apiKeyLocation || "header"}
|
||||
onValueChange={(location) =>
|
||||
handleAuthChange({
|
||||
apiKeyLocation: location as "header" | "query",
|
||||
})
|
||||
}
|
||||
disabled={readonly}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="header">HTTP 헤더</SelectItem>
|
||||
<SelectItem value="query">쿼리 파라미터</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="keyName">키 이름</Label>
|
||||
<Input
|
||||
id="keyName"
|
||||
placeholder={settings.authentication.apiKeyLocation === "query" ? "api_key" : "X-API-Key"}
|
||||
value={settings.authentication.apiKeyHeader || settings.authentication.apiKeyQueryParam || ""}
|
||||
onChange={(e) => {
|
||||
if (settings.authentication?.apiKeyLocation === "query") {
|
||||
handleAuthChange({ apiKeyQueryParam: e.target.value });
|
||||
} else {
|
||||
handleAuthChange({ apiKeyHeader: e.target.value });
|
||||
}
|
||||
}}
|
||||
disabled={readonly}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* 설정 상태 표시 */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{validationErrors.length === 0 ? (
|
||||
<>
|
||||
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||
<span className="text-sm text-green-600">설정이 완료되었습니다</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<AlertCircle className="h-4 w-4 text-orange-500" />
|
||||
<span className="text-sm text-orange-600">{validationErrors.length}개의 설정이 필요합니다</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Badge variant={validationErrors.length === 0 ? "default" : "secondary"}>
|
||||
{validationErrors.length === 0 ? "완료" : "미완료"}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RestApiSettings;
|
||||
Reference in New Issue
Block a user