ERP-node/frontend/components/admin/dashboard/data-sources/ApiConfig.tsx

589 lines
20 KiB
TypeScript

"use client";
import React, { useState, useEffect } from "react";
import { ChartDataSource, QueryResult, KeyValuePair } from "../types";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Plus, X, Play, AlertCircle } from "lucide-react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { ExternalDbConnectionAPI, ExternalApiConnection } from "@/lib/api/externalDbConnection";
// 개별 API 소스 인터페이스
interface ApiSource {
id: string;
endpoint: string;
headers: KeyValuePair[];
queryParams: KeyValuePair[];
jsonPath?: string;
}
interface ApiConfigProps {
dataSource: ChartDataSource;
onChange: (updates: Partial<ChartDataSource>) => void;
onTestResult?: (result: QueryResult) => void;
}
/**
* REST API 설정 컴포넌트
* - API 엔드포인트 설정
* - 헤더 및 쿼리 파라미터 추가
* - JSON Path 설정
*/
export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps) {
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<QueryResult | null>(null);
const [testError, setTestError] = useState<string | null>(null);
const [apiConnections, setApiConnections] = useState<ExternalApiConnection[]>([]);
const [selectedConnectionId, setSelectedConnectionId] = useState<string>("");
// 외부 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);
// 커넥션 설정을 API 설정에 자동 적용
// 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) {
console.log("인증 설정:", connection.auth_type, connection.auth_config);
if (connection.auth_type === "bearer" && connection.auth_config.token) {
headers.push({
id: `header_${Date.now()}_auth`,
key: "Authorization",
value: `Bearer ${connection.auth_config.token}`,
});
console.log("Bearer 토큰 추가");
} else if (connection.auth_type === "api-key") {
console.log("API Key 설정:", connection.auth_config);
if (connection.auth_config.keyName && connection.auth_config.keyValue) {
if (connection.auth_config.keyLocation === "header") {
headers.push({
id: `header_${Date.now()}_apikey`,
key: connection.auth_config.keyName,
value: connection.auth_config.keyValue,
});
console.log(`API Key 헤더 추가: ${connection.auth_config.keyName}=${connection.auth_config.keyValue}`);
} else if (connection.auth_config.keyLocation === "query") {
queryParams.push({
id: `param_${Date.now()}_apikey`,
key: connection.auth_config.keyName,
value: connection.auth_config.keyValue,
});
console.log(
`API Key 쿼리 파라미터 추가: ${connection.auth_config.keyName}=${connection.auth_config.keyValue}`,
);
}
}
} else if (
connection.auth_type === "basic" &&
connection.auth_config.username &&
connection.auth_config.password
) {
const basicAuth = btoa(`${connection.auth_config.username}:${connection.auth_config.password}`);
headers.push({
id: `header_${Date.now()}_basic`,
key: "Authorization",
value: `Basic ${basicAuth}`,
});
console.log("Basic Auth 추가");
}
}
updates.type = "api"; // ⭐ 중요: type을 api로 명시
updates.method = "GET"; // 기본 메서드
updates.headers = headers;
updates.queryParams = queryParams;
console.log("최종 업데이트:", updates);
onChange(updates);
};
// 헤더를 배열로 정규화 (객체 형식 호환)
const normalizeHeaders = (): KeyValuePair[] => {
if (!dataSource.headers) return [];
if (Array.isArray(dataSource.headers)) return dataSource.headers;
// 객체 형식이면 배열로 변환
return Object.entries(dataSource.headers as Record<string, string>).map(([key, value]) => ({
id: `header_${Date.now()}_${Math.random()}`,
key,
value,
}));
};
// 헤더 추가
const addHeader = () => {
const headers = normalizeHeaders();
onChange({
headers: [...headers, { id: `header_${Date.now()}`, key: "", value: "" }],
});
};
// 헤더 제거
const removeHeader = (id: string) => {
const headers = normalizeHeaders();
onChange({ headers: headers.filter((h) => h.id !== id) });
};
// 헤더 업데이트
const updateHeader = (id: string, updates: Partial<KeyValuePair>) => {
const headers = normalizeHeaders();
onChange({
headers: headers.map((h) => (h.id === id ? { ...h, ...updates } : h)),
});
};
// 쿼리 파라미터를 배열로 정규화 (객체 형식 호환)
const normalizeQueryParams = (): KeyValuePair[] => {
if (!dataSource.queryParams) return [];
if (Array.isArray(dataSource.queryParams)) return dataSource.queryParams;
// 객체 형식이면 배열로 변환
return Object.entries(dataSource.queryParams as Record<string, string>).map(([key, value]) => ({
id: `param_${Date.now()}_${Math.random()}`,
key,
value,
}));
};
// 쿼리 파라미터 추가
const addQueryParam = () => {
const queryParams = normalizeQueryParams();
onChange({
queryParams: [...queryParams, { id: `param_${Date.now()}`, key: "", value: "" }],
});
};
// 쿼리 파라미터 제거
const removeQueryParam = (id: string) => {
const queryParams = normalizeQueryParams();
onChange({ queryParams: queryParams.filter((p) => p.id !== id) });
};
// 쿼리 파라미터 업데이트
const updateQueryParam = (id: string, updates: Partial<KeyValuePair>) => {
const queryParams = normalizeQueryParams();
onChange({
queryParams: queryParams.map((p) => (p.id === id ? { ...p, ...updates } : p)),
});
};
// API 테스트
const testApi = async () => {
if (!dataSource.endpoint) {
setTestError("API URL을 입력하세요");
return;
}
// 타일맵 URL 감지 (이미지 파일이므로 테스트 불가)
const isTilemapUrl =
dataSource.endpoint.includes('{z}') &&
dataSource.endpoint.includes('{y}') &&
dataSource.endpoint.includes('{x}');
if (isTilemapUrl) {
setTestError("타일맵 URL은 테스트할 수 없습니다. 지도 위젯에서 직접 확인하세요.");
return;
}
setTesting(true);
setTestError(null);
setTestResult(null);
try {
// 쿼리 파라미터 구성
const params: Record<string, string> = {};
const normalizedQueryParams = normalizeQueryParams();
normalizedQueryParams.forEach(({ key, value }) => {
if (key && value) {
params[key] = value;
}
});
// 헤더 구성
const headers: Record<string, string> = {};
const normalizedHeaders = normalizeHeaders();
normalizedHeaders.forEach(({ key, value }) => {
if (key && value) {
headers[key] = value;
}
});
// 백엔드 프록시를 통한 외부 API 호출 (CORS 우회)
const response = await fetch("http://localhost:8080/api/dashboards/fetch-external-api", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
url: dataSource.endpoint,
method: "GET",
headers: headers,
queryParams: params,
}),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const apiResponse = await response.json();
if (!apiResponse.success) {
throw new Error(apiResponse.message || "외부 API 호출 실패");
}
let apiData = apiResponse.data;
// 텍스트 응답인 경우 파싱
if (apiData && typeof apiData === "object" && "text" in apiData && typeof apiData.text === "string") {
const textData = apiData.text;
// CSV 형식 파싱 (기상청 API)
if (textData.includes("#START7777") || textData.includes(",")) {
const lines = textData.split("\n").filter((line) => line.trim() && !line.startsWith("#"));
const parsedRows = lines.map((line) => {
const values = line.split(",").map((v) => v.trim());
return {
reg_up: values[0] || "",
reg_up_ko: values[1] || "",
reg_id: values[2] || "",
reg_ko: values[3] || "",
tm_fc: values[4] || "",
tm_ef: values[5] || "",
wrn: values[6] || "",
lvl: values[7] || "",
cmd: values[8] || "",
ed_tm: values[9] || "",
};
});
apiData = parsedRows;
} else {
// 일반 텍스트는 그대로 반환
apiData = [{ text: textData }];
}
}
// JSON Path 처리
let data = apiData;
if (dataSource.jsonPath) {
const paths = dataSource.jsonPath.split(".");
for (const path of paths) {
if (data && typeof data === "object" && path in data) {
data = data[path];
} else {
throw new Error(`JSON Path "${dataSource.jsonPath}"에서 데이터를 찾을 수 없습니다`);
}
}
}
// 배열이 아니면 배열로 변환
const rows = Array.isArray(data) ? data : [data];
if (rows.length === 0) {
throw new Error("API 응답에 데이터가 없습니다");
}
// 컬럼 추출 및 타입 분석
const firstRow = rows[0];
const columns = Object.keys(firstRow);
// 각 컬럼의 타입 분석
const columnTypes: Record<string, string> = {};
columns.forEach((col) => {
const value = firstRow[col];
if (value === null || value === undefined) {
columnTypes[col] = "null";
} else if (Array.isArray(value)) {
columnTypes[col] = "array";
} else if (typeof value === "object") {
columnTypes[col] = "object";
} else if (typeof value === "number") {
columnTypes[col] = "number";
} else if (typeof value === "boolean") {
columnTypes[col] = "boolean";
} else {
columnTypes[col] = "string";
}
});
const queryResult: QueryResult = {
columns,
rows,
totalRows: rows.length,
executionTime: 0,
columnTypes, // 타입 정보 추가
};
setTestResult(queryResult);
onTestResult?.(queryResult);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "알 수 없는 오류가 발생했습니다";
setTestError(errorMessage);
} finally {
setTesting(false);
}
};
return (
<div className="space-y-4">
{/* 외부 커넥션 선택 - 항상 표시 */}
<div className="space-y-2">
<Label className="text-xs font-medium text-gray-700"> ()</Label>
<Select value={selectedConnectionId} onValueChange={handleConnectionSelect}>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="저장된 커넥션 선택" />
</SelectTrigger>
<SelectContent className="z-[9999]" position="popper" sideOffset={4}>
<SelectItem value="manual" className="text-xs">
</SelectItem>
{apiConnections.length > 0 ? (
apiConnections.map((conn) => (
<SelectItem key={conn.id} value={String(conn.id)} className="text-xs">
{conn.connection_name}
{conn.description && <span className="ml-1.5 text-[10px] text-gray-500">({conn.description})</span>}
</SelectItem>
))
) : (
<SelectItem value="no-connections" disabled className="text-xs text-gray-500">
</SelectItem>
)}
</SelectContent>
</Select>
<p className="text-[11px] text-gray-500"> REST API </p>
</div>
{/* API URL */}
<div className="space-y-1.5">
<Label className="text-xs font-medium text-gray-700">API URL *</Label>
<Input
type="url"
placeholder="https://api.example.com/data 또는 /api/typ01/url/wrn_now_data.php"
value={dataSource.endpoint || ""}
onChange={(e) => onChange({ endpoint: e.target.value })}
className="h-8 text-xs"
/>
<p className="text-[11px] text-gray-500">
URL base_url ( base_url )
</p>
</div>
{/* 쿼리 파라미터 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs font-medium text-gray-700">URL </Label>
<Button variant="outline" size="sm" onClick={addQueryParam} className="h-6 text-[11px]">
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
{(() => {
const params = normalizeQueryParams();
return params.length > 0 ? (
<div className="space-y-1.5">
{params.map((param) => (
<div key={param.id} className="flex gap-1.5">
<Input
placeholder="key"
value={param.key}
onChange={(e) => updateQueryParam(param.id, { key: e.target.value })}
className="h-7 flex-1 text-xs"
/>
<Input
placeholder="value"
value={param.value}
onChange={(e) => updateQueryParam(param.id, { value: e.target.value })}
className="h-7 flex-1 text-xs"
/>
<button
onClick={() => removeQueryParam(param.id)}
className="flex h-7 w-7 items-center justify-center rounded hover:bg-gray-100"
>
<X className="h-3 w-3" />
</button>
</div>
))}
</div>
) : (
<p className="py-2 text-center text-[11px] text-gray-500"> </p>
);
})()}
<p className="text-[11px] text-gray-500">: category=electronics, limit=10</p>
</div>
{/* 헤더 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs font-medium text-gray-700"> </Label>
<Button variant="outline" size="sm" onClick={addHeader}>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
{/* 빠른 헤더 템플릿 */}
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
size="sm"
onClick={() => {
const headers = normalizeHeaders();
onChange({
headers: [...headers, { id: `header_${Date.now()}`, key: "Authorization", value: "" }],
});
}}
>
+ Authorization
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
const headers = normalizeHeaders();
onChange({
headers: [...headers, { id: `header_${Date.now()}`, key: "Content-Type", value: "application/json" }],
});
}}
>
+ Content-Type
</Button>
</div>
{(() => {
const headers = normalizeHeaders();
return headers.length > 0 ? (
<div className="space-y-2">
{headers.map((header) => (
<div key={header.id} className="flex gap-2">
<Input
placeholder="Header Name"
value={header.key}
onChange={(e) => updateHeader(header.id, { key: e.target.value })}
className="flex-1"
/>
<Input
placeholder="Header Value"
value={header.value}
onChange={(e) => updateHeader(header.id, { value: e.target.value })}
className="flex-1"
type={header.key.toLowerCase().includes("auth") ? "password" : "text"}
/>
<Button variant="ghost" size="icon" onClick={() => removeHeader(header.id)}>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
) : (
<p className="py-2 text-center text-sm text-gray-500"> </p>
);
})()}
</div>
{/* JSON Path */}
<div className="space-y-2">
<Label className="text-xs font-medium text-gray-700">JSON Path ()</Label>
<Input
placeholder="data.results"
value={dataSource.jsonPath || ""}
onChange={(e) => onChange({ jsonPath: e.target.value })}
/>
<p className="text-[11px] text-gray-500">
JSON (: data.results, items, response.data)
<br />
</p>
</div>
{/* 테스트 버튼 */}
<div className="flex justify-end">
<Button onClick={testApi} disabled={!dataSource.endpoint || testing}>
{testing ? (
<>
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
...
</>
) : (
<>
<Play className="mr-2 h-4 w-4" />
API
</>
)}
</Button>
</div>
{/* 테스트 오류 */}
{testError && (
<div className="rounded bg-red-50 px-2 py-2">
<div className="flex items-start gap-2">
<AlertCircle className="mt-0.5 h-5 w-5 flex-shrink-0 text-red-600" />
<div>
<div className="text-sm font-medium text-red-800">API </div>
<div className="mt-1 text-sm text-red-700">{testError}</div>
</div>
</div>
</div>
)}
{/* 테스트 결과 */}
{testResult && (
<div className="rounded bg-green-50 px-2 py-2">
<div className="mb-2 text-sm font-medium text-green-800">API </div>
<div className="space-y-1 text-xs text-green-700">
<div> {testResult.rows.length} </div>
<div>: {testResult.columns.join(", ")}</div>
</div>
</div>
)}
</div>
);
}