498 lines
18 KiB
TypeScript
498 lines
18 KiB
TypeScript
"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;
|