589 lines
20 KiB
TypeScript
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>
|
|
);
|
|
}
|