ERP-node/frontend/components/dataflow/external-call/RestApiSettings.tsx

660 lines
24 KiB
TypeScript
Raw Normal View History

2025-09-26 17:11:18 +09:00
"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-destructive" : ""}
2025-09-26 17:11:18 +09:00
/>
<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;