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

411 lines
14 KiB
TypeScript

"use client";
import React, { useState } from "react";
import { ChartDataSource, QueryResult, KeyValuePair } from "../types";
import { Card } from "@/components/ui/card";
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";
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 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;
}
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 호출 실패");
}
const apiData = apiResponse.data;
// 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>
<h3 className="text-lg font-semibold text-gray-800">2단계: REST API </h3>
<p className="mt-1 text-sm text-gray-600"> API에서 </p>
</div>
{/* API URL */}
<Card className="space-y-4 p-4">
<div>
<Label className="text-sm font-medium text-gray-700">API URL *</Label>
<Input
type="url"
placeholder="https://api.example.com/data"
value={dataSource.endpoint || ""}
onChange={(e) => onChange({ endpoint: e.target.value })}
className="mt-2"
/>
<p className="mt-1 text-xs text-gray-500">GET API </p>
</div>
{/* HTTP 메서드 (고정) */}
<div>
<Label className="text-sm font-medium text-gray-700">HTTP </Label>
<div className="mt-2 rounded border border-gray-300 bg-gray-100 p-2 text-sm text-gray-700">GET ()</div>
<p className="mt-1 text-xs text-gray-500"> GET </p>
</div>
</Card>
{/* 쿼리 파라미터 */}
<Card className="space-y-4 p-4">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium text-gray-700">URL </Label>
<Button variant="outline" size="sm" onClick={addQueryParam}>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
{(() => {
const params = normalizeQueryParams();
return params.length > 0 ? (
<div className="space-y-2">
{params.map((param) => (
<div key={param.id} className="flex gap-2">
<Input
placeholder="key"
value={param.key}
onChange={(e) => updateQueryParam(param.id, { key: e.target.value })}
className="flex-1"
/>
<Input
placeholder="value"
value={param.value}
onChange={(e) => updateQueryParam(param.id, { value: e.target.value })}
className="flex-1"
/>
<Button variant="ghost" size="icon" onClick={() => removeQueryParam(param.id)}>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
) : (
<p className="py-2 text-center text-sm text-gray-500"> </p>
);
})()}
<p className="text-xs text-gray-500">: category=electronics, limit=10</p>
</Card>
{/* 헤더 */}
<Card className="space-y-4 p-4">
<div className="flex items-center justify-between">
<Label className="text-sm 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>
);
})()}
</Card>
{/* JSON Path */}
<Card className="space-y-2 p-4">
<Label className="text-sm 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-xs text-gray-500">
JSON (: data.results, items, response.data)
<br />
</p>
</Card>
{/* 테스트 버튼 */}
<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 && (
<Card className="border-red-200 bg-red-50 p-4">
<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>
</Card>
)}
{/* 테스트 결과 */}
{testResult && (
<Card className="border-green-200 bg-green-50 p-4">
<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>
</Card>
)}
</div>
);
}