456 lines
16 KiB
TypeScript
456 lines
16 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useEffect } from "react";
|
|
import { X, Save, TestTube, ChevronDown, ChevronUp } from "lucide-react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
|
import { useToast } from "@/hooks/use-toast";
|
|
import {
|
|
ExternalRestApiConnectionAPI,
|
|
ExternalRestApiConnection,
|
|
AuthType,
|
|
RestApiTestResult,
|
|
} from "@/lib/api/externalRestApiConnection";
|
|
import { HeadersManager } from "./HeadersManager";
|
|
import { AuthenticationConfig } from "./AuthenticationConfig";
|
|
import { Badge } from "@/components/ui/badge";
|
|
|
|
interface RestApiConnectionModalProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
onSave: () => void;
|
|
connection?: ExternalRestApiConnection;
|
|
}
|
|
|
|
export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }: RestApiConnectionModalProps) {
|
|
const { toast } = useToast();
|
|
|
|
// 폼 상태
|
|
const [connectionName, setConnectionName] = useState("");
|
|
const [description, setDescription] = useState("");
|
|
const [baseUrl, setBaseUrl] = useState("");
|
|
const [endpointPath, setEndpointPath] = useState("");
|
|
const [defaultHeaders, setDefaultHeaders] = useState<Record<string, string>>({});
|
|
const [authType, setAuthType] = useState<AuthType>("none");
|
|
const [authConfig, setAuthConfig] = useState<any>({});
|
|
const [timeout, setTimeout] = useState(30000);
|
|
const [retryCount, setRetryCount] = useState(0);
|
|
const [retryDelay, setRetryDelay] = useState(1000);
|
|
const [isActive, setIsActive] = useState(true);
|
|
|
|
// UI 상태
|
|
const [showAdvanced, setShowAdvanced] = useState(false);
|
|
const [testEndpoint, setTestEndpoint] = useState("");
|
|
const [testing, setTesting] = useState(false);
|
|
const [testResult, setTestResult] = useState<RestApiTestResult | null>(null);
|
|
const [testRequestUrl, setTestRequestUrl] = useState<string>("");
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
// 기존 연결 데이터 로드
|
|
useEffect(() => {
|
|
if (connection) {
|
|
setConnectionName(connection.connection_name);
|
|
setDescription(connection.description || "");
|
|
setBaseUrl(connection.base_url);
|
|
setEndpointPath(connection.endpoint_path || "");
|
|
setDefaultHeaders(connection.default_headers || {});
|
|
setAuthType(connection.auth_type);
|
|
setAuthConfig(connection.auth_config || {});
|
|
setTimeout(connection.timeout || 30000);
|
|
setRetryCount(connection.retry_count || 0);
|
|
setRetryDelay(connection.retry_delay || 1000);
|
|
setIsActive(connection.is_active === "Y");
|
|
} else {
|
|
// 초기화
|
|
setConnectionName("");
|
|
setDescription("");
|
|
setBaseUrl("");
|
|
setEndpointPath("");
|
|
setDefaultHeaders({ "Content-Type": "application/json" });
|
|
setAuthType("none");
|
|
setAuthConfig({});
|
|
setTimeout(30000);
|
|
setRetryCount(0);
|
|
setRetryDelay(1000);
|
|
setIsActive(true);
|
|
}
|
|
|
|
setTestResult(null);
|
|
setTestEndpoint("");
|
|
setTestRequestUrl("");
|
|
}, [connection, isOpen]);
|
|
|
|
// 연결 테스트
|
|
const handleTest = async () => {
|
|
// 유효성 검증
|
|
if (!baseUrl.trim()) {
|
|
toast({
|
|
title: "입력 오류",
|
|
description: "기본 URL을 입력해주세요.",
|
|
variant: "destructive",
|
|
});
|
|
return;
|
|
}
|
|
|
|
setTesting(true);
|
|
setTestResult(null);
|
|
|
|
// 사용자가 테스트하려는 실제 외부 API URL 설정
|
|
const fullUrl = testEndpoint ? `${baseUrl}${testEndpoint}` : baseUrl;
|
|
setTestRequestUrl(fullUrl);
|
|
|
|
try {
|
|
const result = await ExternalRestApiConnectionAPI.testConnection({
|
|
base_url: baseUrl,
|
|
endpoint: testEndpoint || undefined,
|
|
headers: defaultHeaders,
|
|
auth_type: authType,
|
|
auth_config: authConfig,
|
|
timeout,
|
|
});
|
|
|
|
setTestResult(result);
|
|
|
|
if (result.success) {
|
|
toast({
|
|
title: "연결 성공",
|
|
description: `응답 시간: ${result.response_time}ms`,
|
|
});
|
|
} else {
|
|
toast({
|
|
title: "연결 실패",
|
|
description: result.message,
|
|
variant: "destructive",
|
|
});
|
|
}
|
|
} catch (error) {
|
|
toast({
|
|
title: "테스트 오류",
|
|
description: error instanceof Error ? error.message : "알 수 없는 오류",
|
|
variant: "destructive",
|
|
});
|
|
} finally {
|
|
setTesting(false);
|
|
}
|
|
};
|
|
|
|
// 저장
|
|
const handleSave = async () => {
|
|
// 유효성 검증
|
|
if (!connectionName.trim()) {
|
|
toast({
|
|
title: "입력 오류",
|
|
description: "연결명을 입력해주세요.",
|
|
variant: "destructive",
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (!baseUrl.trim()) {
|
|
toast({
|
|
title: "입력 오류",
|
|
description: "기본 URL을 입력해주세요.",
|
|
variant: "destructive",
|
|
});
|
|
return;
|
|
}
|
|
|
|
// URL 형식 검증
|
|
try {
|
|
new URL(baseUrl);
|
|
} catch {
|
|
toast({
|
|
title: "입력 오류",
|
|
description: "올바른 URL 형식이 아닙니다.",
|
|
variant: "destructive",
|
|
});
|
|
return;
|
|
}
|
|
|
|
setSaving(true);
|
|
|
|
try {
|
|
const data: ExternalRestApiConnection = {
|
|
connection_name: connectionName,
|
|
description: description || undefined,
|
|
base_url: baseUrl,
|
|
endpoint_path: endpointPath || undefined,
|
|
default_headers: defaultHeaders,
|
|
auth_type: authType,
|
|
auth_config: authType === "none" ? undefined : authConfig,
|
|
timeout,
|
|
retry_count: retryCount,
|
|
retry_delay: retryDelay,
|
|
company_code: "*",
|
|
is_active: isActive ? "Y" : "N",
|
|
};
|
|
|
|
if (connection?.id) {
|
|
await ExternalRestApiConnectionAPI.updateConnection(connection.id, data);
|
|
toast({
|
|
title: "수정 완료",
|
|
description: "연결이 수정되었습니다.",
|
|
});
|
|
} else {
|
|
await ExternalRestApiConnectionAPI.createConnection(data);
|
|
toast({
|
|
title: "생성 완료",
|
|
description: "연결이 생성되었습니다.",
|
|
});
|
|
}
|
|
|
|
onSave();
|
|
onClose();
|
|
} catch (error) {
|
|
toast({
|
|
title: "저장 실패",
|
|
description: error instanceof Error ? error.message : "알 수 없는 오류",
|
|
variant: "destructive",
|
|
});
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
|
<DialogContent className="max-h-[90vh] max-w-3xl overflow-y-auto">
|
|
<DialogHeader>
|
|
<DialogTitle>{connection ? "REST API 연결 수정" : "새 REST API 연결 추가"}</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-6 py-4">
|
|
{/* 기본 정보 */}
|
|
<div className="space-y-4">
|
|
<h3 className="text-sm font-semibold">기본 정보</h3>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="connection-name">
|
|
연결명 <span className="text-destructive">*</span>
|
|
</Label>
|
|
<Input
|
|
id="connection-name"
|
|
value={connectionName}
|
|
onChange={(e) => setConnectionName(e.target.value)}
|
|
placeholder="예: 날씨 API"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="description">설명</Label>
|
|
<Textarea
|
|
id="description"
|
|
value={description}
|
|
onChange={(e) => setDescription(e.target.value)}
|
|
placeholder="연결에 대한 설명을 입력하세요"
|
|
rows={2}
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="base-url">
|
|
기본 URL <span className="text-destructive">*</span>
|
|
</Label>
|
|
<Input
|
|
id="base-url"
|
|
value={baseUrl}
|
|
onChange={(e) => setBaseUrl(e.target.value)}
|
|
placeholder="https://api.example.com"
|
|
/>
|
|
<p className="text-muted-foreground text-xs">
|
|
도메인 부분만 입력하세요 (예: https://apihub.kma.go.kr)
|
|
</p>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="endpoint-path">엔드포인트 경로</Label>
|
|
<Input
|
|
id="endpoint-path"
|
|
value={endpointPath}
|
|
onChange={(e) => setEndpointPath(e.target.value)}
|
|
placeholder="/api/typ01/url/wrn_now_data.php"
|
|
/>
|
|
<p className="text-muted-foreground text-xs">
|
|
API 엔드포인트 경로를 입력하세요 (선택사항)
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex items-center space-x-2">
|
|
<Switch id="is-active" checked={isActive} onCheckedChange={setIsActive} />
|
|
<Label htmlFor="is-active" className="cursor-pointer">
|
|
활성 상태
|
|
</Label>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 헤더 관리 */}
|
|
<div className="space-y-4">
|
|
<h3 className="text-sm font-semibold">헤더 설정</h3>
|
|
<HeadersManager headers={defaultHeaders} onChange={setDefaultHeaders} />
|
|
</div>
|
|
|
|
{/* 인증 설정 */}
|
|
<div className="space-y-4">
|
|
<h3 className="text-sm font-semibold">인증 설정</h3>
|
|
<AuthenticationConfig
|
|
authType={authType}
|
|
authConfig={authConfig}
|
|
onAuthTypeChange={setAuthType}
|
|
onAuthConfigChange={setAuthConfig}
|
|
/>
|
|
</div>
|
|
|
|
{/* 고급 설정 */}
|
|
<div className="space-y-4">
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowAdvanced(!showAdvanced)}
|
|
className="hover:text-primary flex items-center space-x-2 text-sm font-semibold transition-colors"
|
|
>
|
|
<span>고급 설정</span>
|
|
{showAdvanced ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
|
</button>
|
|
|
|
{showAdvanced && (
|
|
<div className="bg-muted space-y-4 rounded-md border p-4">
|
|
<div className="grid grid-cols-3 gap-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="timeout">타임아웃 (ms)</Label>
|
|
<Input
|
|
id="timeout"
|
|
type="number"
|
|
value={timeout}
|
|
onChange={(e) => setTimeout(parseInt(e.target.value) || 30000)}
|
|
min={1000}
|
|
max={120000}
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="retry-count">재시도 횟수</Label>
|
|
<Input
|
|
id="retry-count"
|
|
type="number"
|
|
value={retryCount}
|
|
onChange={(e) => setRetryCount(parseInt(e.target.value) || 0)}
|
|
min={0}
|
|
max={5}
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="retry-delay">재시도 간격 (ms)</Label>
|
|
<Input
|
|
id="retry-delay"
|
|
type="number"
|
|
value={retryDelay}
|
|
onChange={(e) => setRetryDelay(parseInt(e.target.value) || 1000)}
|
|
min={100}
|
|
max={10000}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 연결 테스트 */}
|
|
<div className="space-y-4">
|
|
<h3 className="text-sm font-semibold">연결 테스트</h3>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="test-endpoint">테스트 엔드포인트 (선택)</Label>
|
|
<Input
|
|
id="test-endpoint"
|
|
value={testEndpoint}
|
|
onChange={(e) => setTestEndpoint(e.target.value)}
|
|
placeholder="엔드포인트 또는 빈칸(기본 URL만 테스트)"
|
|
/>
|
|
</div>
|
|
|
|
<Button type="button" variant="outline" onClick={handleTest} disabled={testing}>
|
|
<TestTube className="mr-2 h-4 w-4" />
|
|
{testing ? "테스트 중..." : "연결 테스트"}
|
|
</Button>
|
|
|
|
{/* 테스트 요청 정보 표시 */}
|
|
{testRequestUrl && (
|
|
<div className="bg-muted/30 space-y-3 rounded-md border p-3">
|
|
<div>
|
|
<div className="text-muted-foreground mb-1 text-xs font-medium">테스트 요청 URL</div>
|
|
<code className="text-foreground block text-xs break-all">GET {testRequestUrl}</code>
|
|
</div>
|
|
|
|
{Object.keys(defaultHeaders).length > 0 && (
|
|
<div>
|
|
<div className="text-muted-foreground mb-1 text-xs font-medium">요청 헤더</div>
|
|
<div className="space-y-1">
|
|
{Object.entries(defaultHeaders).map(([key, value]) => (
|
|
<code key={key} className="text-foreground block text-xs">
|
|
{key}: {value}
|
|
</code>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{authType !== "none" && (
|
|
<div>
|
|
<div className="text-muted-foreground mb-1 text-xs font-medium">인증 방식</div>
|
|
<code className="text-foreground block text-xs">
|
|
{authType === "api-key" && "API Key"}
|
|
{authType === "bearer" && "Bearer Token"}
|
|
{authType === "basic" && "Basic Auth"}
|
|
{authType === "oauth2" && "OAuth 2.0"}
|
|
</code>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{testResult && (
|
|
<div
|
|
className={`rounded-md border p-4 ${
|
|
testResult.success ? "border-green-200 bg-green-50" : "border-red-200 bg-red-50"
|
|
}`}
|
|
>
|
|
<div className="mb-2 flex items-center justify-between">
|
|
<Badge variant={testResult.success ? "default" : "destructive"}>
|
|
{testResult.success ? "성공" : "실패"}
|
|
</Badge>
|
|
{testResult.response_time && (
|
|
<span className="text-sm text-gray-600">응답 시간: {testResult.response_time}ms</span>
|
|
)}
|
|
</div>
|
|
|
|
<p className="text-sm">{testResult.message}</p>
|
|
|
|
{testResult.status_code && (
|
|
<p className="mt-1 text-xs text-gray-500">상태 코드: {testResult.status_code}</p>
|
|
)}
|
|
|
|
{testResult.error_details && <p className="mt-2 text-xs text-red-600">{testResult.error_details}</p>}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button type="button" variant="outline" onClick={onClose}>
|
|
<X className="mr-2 h-4 w-4" />
|
|
취소
|
|
</Button>
|
|
<Button type="button" onClick={handleSave} disabled={saving}>
|
|
<Save className="mr-2 h-4 w-4" />
|
|
{saving ? "저장 중..." : connection ? "수정" : "생성"}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|