외부호출 기능(rest API)

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

View File

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

View File

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

View File

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