restapi 여러개 띄우는거 작업 가능하게 하는거 진행중
This commit is contained in:
@@ -0,0 +1,529 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { ChartDataSource, KeyValuePair } from "@/components/admin/dashboard/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Plus, Trash2, Loader2, CheckCircle, XCircle } from "lucide-react";
|
||||
import { ExternalDbConnectionAPI, ExternalApiConnection } from "@/lib/api/externalDbConnection";
|
||||
|
||||
interface MultiApiConfigProps {
|
||||
dataSource: ChartDataSource;
|
||||
onChange: (updates: Partial<ChartDataSource>) => void;
|
||||
onTestResult?: (data: any) => void; // 테스트 결과 데이터 전달
|
||||
}
|
||||
|
||||
export default function MultiApiConfig({ dataSource, onChange, onTestResult }: MultiApiConfigProps) {
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
|
||||
const [apiConnections, setApiConnections] = useState<ExternalApiConnection[]>([]);
|
||||
const [selectedConnectionId, setSelectedConnectionId] = useState<string>("");
|
||||
|
||||
console.log("🔧 MultiApiConfig - dataSource:", dataSource);
|
||||
|
||||
// 외부 API 커넥션 목록 로드
|
||||
useEffect(() => {
|
||||
const loadApiConnections = async () => {
|
||||
const connections = await ExternalDbConnectionAPI.getApiConnections({ is_active: "Y" });
|
||||
setApiConnections(connections);
|
||||
};
|
||||
loadApiConnections();
|
||||
}, []);
|
||||
|
||||
// 외부 커넥션 선택 핸들러
|
||||
const handleConnectionSelect = async (connectionId: string) => {
|
||||
setSelectedConnectionId(connectionId);
|
||||
|
||||
if (!connectionId || connectionId === "manual") {
|
||||
return;
|
||||
}
|
||||
|
||||
const connection = await ExternalDbConnectionAPI.getApiConnectionById(Number(connectionId));
|
||||
if (!connection) {
|
||||
console.error("커넥션을 찾을 수 없습니다:", connectionId);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("불러온 커넥션:", connection);
|
||||
|
||||
// base_url과 endpoint_path를 조합하여 전체 URL 생성
|
||||
const fullEndpoint = connection.endpoint_path
|
||||
? `${connection.base_url}${connection.endpoint_path}`
|
||||
: connection.base_url;
|
||||
|
||||
console.log("전체 엔드포인트:", fullEndpoint);
|
||||
|
||||
const updates: Partial<ChartDataSource> = {
|
||||
endpoint: fullEndpoint,
|
||||
};
|
||||
|
||||
const headers: KeyValuePair[] = [];
|
||||
const queryParams: KeyValuePair[] = [];
|
||||
|
||||
// 기본 헤더가 있으면 적용
|
||||
if (connection.default_headers && Object.keys(connection.default_headers).length > 0) {
|
||||
Object.entries(connection.default_headers).forEach(([key, value]) => {
|
||||
headers.push({
|
||||
id: `header_${Date.now()}_${Math.random()}`,
|
||||
key,
|
||||
value,
|
||||
});
|
||||
});
|
||||
console.log("기본 헤더 적용:", headers);
|
||||
}
|
||||
|
||||
// 인증 설정이 있으면 헤더 또는 쿼리 파라미터에 추가
|
||||
if (connection.auth_type && connection.auth_type !== "none" && connection.auth_config) {
|
||||
const authConfig = connection.auth_config;
|
||||
|
||||
switch (connection.auth_type) {
|
||||
case "api-key":
|
||||
if (authConfig.keyLocation === "header" && authConfig.keyName && authConfig.keyValue) {
|
||||
headers.push({
|
||||
id: `auth_header_${Date.now()}`,
|
||||
key: authConfig.keyName,
|
||||
value: authConfig.keyValue,
|
||||
});
|
||||
console.log("API Key 헤더 추가:", authConfig.keyName);
|
||||
} else if (authConfig.keyLocation === "query" && authConfig.keyName && authConfig.keyValue) {
|
||||
queryParams.push({
|
||||
id: `auth_query_${Date.now()}`,
|
||||
key: authConfig.keyName,
|
||||
value: authConfig.keyValue,
|
||||
});
|
||||
console.log("API Key 쿼리 파라미터 추가:", authConfig.keyName);
|
||||
}
|
||||
break;
|
||||
|
||||
case "bearer":
|
||||
if (authConfig.token) {
|
||||
headers.push({
|
||||
id: `auth_bearer_${Date.now()}`,
|
||||
key: "Authorization",
|
||||
value: `Bearer ${authConfig.token}`,
|
||||
});
|
||||
console.log("Bearer Token 헤더 추가");
|
||||
}
|
||||
break;
|
||||
|
||||
case "basic":
|
||||
if (authConfig.username && authConfig.password) {
|
||||
const credentials = btoa(`${authConfig.username}:${authConfig.password}`);
|
||||
headers.push({
|
||||
id: `auth_basic_${Date.now()}`,
|
||||
key: "Authorization",
|
||||
value: `Basic ${credentials}`,
|
||||
});
|
||||
console.log("Basic Auth 헤더 추가");
|
||||
}
|
||||
break;
|
||||
|
||||
case "oauth2":
|
||||
if (authConfig.accessToken) {
|
||||
headers.push({
|
||||
id: `auth_oauth_${Date.now()}`,
|
||||
key: "Authorization",
|
||||
value: `Bearer ${authConfig.accessToken}`,
|
||||
});
|
||||
console.log("OAuth2 Token 헤더 추가");
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 헤더와 쿼리 파라미터 적용
|
||||
if (headers.length > 0) {
|
||||
updates.headers = headers;
|
||||
}
|
||||
if (queryParams.length > 0) {
|
||||
updates.queryParams = queryParams;
|
||||
}
|
||||
|
||||
console.log("최종 업데이트:", updates);
|
||||
onChange(updates);
|
||||
};
|
||||
|
||||
// 헤더 추가
|
||||
const handleAddHeader = () => {
|
||||
const headers = dataSource.headers || [];
|
||||
onChange({
|
||||
headers: [...headers, { id: Date.now().toString(), key: "", value: "" }],
|
||||
});
|
||||
};
|
||||
|
||||
// 헤더 삭제
|
||||
const handleDeleteHeader = (id: string) => {
|
||||
const headers = (dataSource.headers || []).filter((h) => h.id !== id);
|
||||
onChange({ headers });
|
||||
};
|
||||
|
||||
// 헤더 업데이트
|
||||
const handleUpdateHeader = (id: string, field: "key" | "value", value: string) => {
|
||||
const headers = (dataSource.headers || []).map((h) =>
|
||||
h.id === id ? { ...h, [field]: value } : h
|
||||
);
|
||||
onChange({ headers });
|
||||
};
|
||||
|
||||
// 쿼리 파라미터 추가
|
||||
const handleAddQueryParam = () => {
|
||||
const queryParams = dataSource.queryParams || [];
|
||||
onChange({
|
||||
queryParams: [...queryParams, { id: Date.now().toString(), key: "", value: "" }],
|
||||
});
|
||||
};
|
||||
|
||||
// 쿼리 파라미터 삭제
|
||||
const handleDeleteQueryParam = (id: string) => {
|
||||
const queryParams = (dataSource.queryParams || []).filter((q) => q.id !== id);
|
||||
onChange({ queryParams });
|
||||
};
|
||||
|
||||
// 쿼리 파라미터 업데이트
|
||||
const handleUpdateQueryParam = (id: string, field: "key" | "value", value: string) => {
|
||||
const queryParams = (dataSource.queryParams || []).map((q) =>
|
||||
q.id === id ? { ...q, [field]: value } : q
|
||||
);
|
||||
onChange({ queryParams });
|
||||
};
|
||||
|
||||
// API 테스트
|
||||
const handleTestApi = async () => {
|
||||
if (!dataSource.endpoint) {
|
||||
setTestResult({ success: false, message: "API URL을 입력해주세요" });
|
||||
return;
|
||||
}
|
||||
|
||||
setTesting(true);
|
||||
setTestResult(null);
|
||||
|
||||
try {
|
||||
const queryParams: Record<string, string> = {};
|
||||
(dataSource.queryParams || []).forEach((param) => {
|
||||
if (param.key && param.value) {
|
||||
queryParams[param.key] = param.value;
|
||||
}
|
||||
});
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
(dataSource.headers || []).forEach((header) => {
|
||||
if (header.key && header.value) {
|
||||
headers[header.key] = header.value;
|
||||
}
|
||||
});
|
||||
|
||||
const response = await fetch("/api/dashboards/fetch-external-api", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
url: dataSource.endpoint,
|
||||
method: dataSource.method || "GET",
|
||||
headers,
|
||||
queryParams,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
// 텍스트 데이터 파싱 함수 (MapTestWidgetV2와 동일)
|
||||
const parseTextData = (text: string): any[] => {
|
||||
try {
|
||||
console.log("🔍 텍스트 파싱 시작 (처음 500자):", text.substring(0, 500));
|
||||
|
||||
const lines = text.split('\n').filter(line => {
|
||||
const trimmed = line.trim();
|
||||
return trimmed &&
|
||||
!trimmed.startsWith('#') &&
|
||||
!trimmed.startsWith('=') &&
|
||||
!trimmed.startsWith('---');
|
||||
});
|
||||
|
||||
console.log(`📝 유효한 라인: ${lines.length}개`);
|
||||
|
||||
if (lines.length === 0) return [];
|
||||
|
||||
const result: any[] = [];
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const values = line.split(',').map(v => v.trim().replace(/,=$/g, ''));
|
||||
|
||||
// 기상특보 형식: 지역코드, 지역명, 하위코드, 하위지역명, 발표시각, 특보종류, 등급, 발표상태, 설명
|
||||
if (values.length >= 4) {
|
||||
const obj: any = {
|
||||
code: values[0] || '', // 지역 코드 (예: L1070000)
|
||||
region: values[1] || '', // 지역명 (예: 경상북도)
|
||||
subCode: values[2] || '', // 하위 코드 (예: L1071600)
|
||||
subRegion: values[3] || '', // 하위 지역명 (예: 영주시)
|
||||
tmFc: values[4] || '', // 발표시각
|
||||
type: values[5] || '', // 특보종류 (강풍, 호우 등)
|
||||
level: values[6] || '', // 등급 (주의, 경보)
|
||||
status: values[7] || '', // 발표상태
|
||||
description: values.slice(8).join(', ').trim() || '',
|
||||
name: values[3] || values[1] || values[0], // 하위 지역명 우선
|
||||
};
|
||||
|
||||
result.push(obj);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("📊 파싱 결과:", result.length, "개");
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error("❌ 텍스트 파싱 오류:", error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
// JSON Path로 데이터 추출
|
||||
let data = result.data;
|
||||
|
||||
// 텍스트 데이터 체크 (기상청 API 등)
|
||||
if (data && typeof data === 'object' && data.text && typeof data.text === 'string') {
|
||||
console.log("📄 텍스트 형식 데이터 감지, CSV 파싱 시도");
|
||||
const parsedData = parseTextData(data.text);
|
||||
if (parsedData.length > 0) {
|
||||
console.log(`✅ CSV 파싱 성공: ${parsedData.length}개 행`);
|
||||
data = parsedData;
|
||||
}
|
||||
} else if (dataSource.jsonPath) {
|
||||
const pathParts = dataSource.jsonPath.split(".");
|
||||
for (const part of pathParts) {
|
||||
data = data?.[part];
|
||||
}
|
||||
}
|
||||
|
||||
const rows = Array.isArray(data) ? data : [data];
|
||||
|
||||
// 위도/경도 또는 coordinates 필드 또는 지역 코드 체크
|
||||
const hasLocationData = rows.some((row) => {
|
||||
const hasLatLng = (row.lat || row.latitude) && (row.lng || row.longitude);
|
||||
const hasCoordinates = row.coordinates && Array.isArray(row.coordinates);
|
||||
const hasRegionCode = row.code || row.areaCode || row.regionCode;
|
||||
return hasLatLng || hasCoordinates || hasRegionCode;
|
||||
});
|
||||
|
||||
if (hasLocationData) {
|
||||
const markerCount = rows.filter(r =>
|
||||
((r.lat || r.latitude) && (r.lng || r.longitude)) ||
|
||||
r.code || r.areaCode || r.regionCode
|
||||
).length;
|
||||
const polygonCount = rows.filter(r => r.coordinates && Array.isArray(r.coordinates)).length;
|
||||
|
||||
setTestResult({
|
||||
success: true,
|
||||
message: `API 연결 성공 - 마커 ${markerCount}개, 영역 ${polygonCount}개 발견`
|
||||
});
|
||||
|
||||
// 부모에게 테스트 결과 전달 (지도 미리보기용)
|
||||
if (onTestResult) {
|
||||
onTestResult(rows);
|
||||
}
|
||||
} else {
|
||||
setTestResult({
|
||||
success: true,
|
||||
message: `API 연결 성공 - ${rows.length}개 데이터 (위치 정보 없음)`
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setTestResult({ success: false, message: result.message || "API 호출 실패" });
|
||||
}
|
||||
} catch (error: any) {
|
||||
setTestResult({ success: false, message: error.message || "네트워크 오류" });
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 rounded-lg border p-4">
|
||||
<h5 className="text-sm font-semibold">REST API 설정</h5>
|
||||
|
||||
{/* 외부 연결 선택 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`connection-${dataSource.id}`} className="text-xs">
|
||||
외부 연결 선택
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedConnectionId}
|
||||
onValueChange={handleConnectionSelect}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="외부 연결 선택 (선택사항)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="manual" className="text-xs">
|
||||
직접 입력
|
||||
</SelectItem>
|
||||
{apiConnections.map((conn) => (
|
||||
<SelectItem key={conn.id} value={conn.id?.toString() || ""} className="text-xs">
|
||||
{conn.connection_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
외부 연결을 선택하면 API URL이 자동으로 입력됩니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* API URL (직접 입력 또는 수정) */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`endpoint-${dataSource.id}`} className="text-xs">
|
||||
API URL *
|
||||
</Label>
|
||||
<Input
|
||||
id={`endpoint-${dataSource.id}`}
|
||||
value={dataSource.endpoint || ""}
|
||||
onChange={(e) => {
|
||||
console.log("📝 API URL 변경:", e.target.value);
|
||||
onChange({ endpoint: e.target.value });
|
||||
}}
|
||||
placeholder="https://api.example.com/data"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
외부 연결을 선택하거나 직접 입력할 수 있습니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* JSON Path */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`jsonPath-\${dataSource.id}`} className="text-xs">
|
||||
JSON Path (선택)
|
||||
</Label>
|
||||
<Input
|
||||
id={`jsonPath-\${dataSource.id}`}
|
||||
value={dataSource.jsonPath || ""}
|
||||
onChange={(e) => onChange({ jsonPath: e.target.value })}
|
||||
placeholder="예: data.results"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
응답 JSON에서 데이터를 추출할 경로
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 쿼리 파라미터 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">쿼리 파라미터</Label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleAddQueryParam}
|
||||
className="h-6 gap-1 text-xs"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
{(dataSource.queryParams || []).map((param) => (
|
||||
<div key={param.id} className="flex gap-2">
|
||||
<Input
|
||||
value={param.key}
|
||||
onChange={(e) => handleUpdateQueryParam(param.id, "key", e.target.value)}
|
||||
placeholder="키"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<Input
|
||||
value={param.value}
|
||||
onChange={(e) => handleUpdateQueryParam(param.id, "value", e.target.value)}
|
||||
placeholder="값"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteQueryParam(param.id)}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 헤더 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">헤더</Label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleAddHeader}
|
||||
className="h-6 gap-1 text-xs"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
{(dataSource.headers || []).map((header) => (
|
||||
<div key={header.id} className="flex gap-2">
|
||||
<Input
|
||||
value={header.key}
|
||||
onChange={(e) => handleUpdateHeader(header.id, "key", e.target.value)}
|
||||
placeholder="키"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<Input
|
||||
value={header.value}
|
||||
onChange={(e) => handleUpdateHeader(header.id, "value", e.target.value)}
|
||||
placeholder="값"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteHeader(header.id)}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 테스트 버튼 */}
|
||||
<div className="space-y-2 border-t pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleTestApi}
|
||||
disabled={testing || !dataSource.endpoint}
|
||||
className="h-8 w-full gap-2 text-xs"
|
||||
>
|
||||
{testing ? (
|
||||
<>
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
테스트 중...
|
||||
</>
|
||||
) : (
|
||||
"API 테스트"
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{testResult && (
|
||||
<div
|
||||
className={`flex items-center gap-2 rounded-md p-2 text-xs \${
|
||||
testResult.success
|
||||
? "bg-green-50 text-green-700"
|
||||
: "bg-red-50 text-red-700"
|
||||
}`}
|
||||
>
|
||||
{testResult.success ? (
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
) : (
|
||||
<XCircle className="h-3 w-3" />
|
||||
)}
|
||||
{testResult.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user