리스트 위젯 REST API 기능 개선

This commit is contained in:
dohyeons 2025-12-11 10:48:48 +09:00
parent e84764dc2b
commit bccb8a6330
5 changed files with 211 additions and 25 deletions

View File

@ -702,6 +702,15 @@ export class DashboardController {
requestConfig.data = body;
}
// 디버깅 로그: 실제 요청 정보 출력
logger.info(`[fetchExternalApi] 요청 정보:`, {
url: requestConfig.url,
method: requestConfig.method,
headers: requestConfig.headers,
body: requestConfig.data,
externalConnectionId,
});
// TLS 인증서 검증 예외 처리 (thiratis.com 등 내부망/레거시 API 대응)
// ExternalRestApiConnectionService와 동일한 로직 적용
const bypassDomains = ["thiratis.com"];

View File

@ -296,13 +296,20 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge
element.subtype === "custom-metric-v2" ||
element.subtype === "risk-alert-v2";
// 리스트 위젯이 단일 데이터 소스 UI를 사용하는 경우, dataSource를 dataSources로 변환
let finalDataSources = dataSources;
if (isMultiDataSourceWidget && element.subtype === "list-v2" && dataSources.length === 0 && dataSource.endpoint) {
// 단일 데이터 소스가 설정되어 있으면 dataSources 배열로 변환
finalDataSources = [dataSource];
}
// chartConfig 구성 (위젯 타입별로 다르게 처리)
let finalChartConfig = { ...chartConfig };
if (isMultiDataSourceWidget) {
finalChartConfig = {
...finalChartConfig,
dataSources: dataSources,
dataSources: finalDataSources,
};
}
@ -325,7 +332,7 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge
// 다중 데이터 소스 위젯은 dataSources도 포함 (빈 배열도 허용 - 연결 해제)
...(isMultiDataSourceWidget
? {
dataSources: dataSources,
dataSources: finalDataSources,
}
: {}),
}

View File

@ -137,9 +137,18 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps
}
updates.type = "api"; // ⭐ 중요: type을 api로 명시
updates.method = "GET"; // 기본 메서드
updates.method = (connection.default_method as "GET" | "POST" | "PUT" | "PATCH" | "DELETE") || "GET"; // 커넥션에 설정된 메서드 사용
updates.headers = headers;
updates.queryParams = queryParams;
// Request Body가 있으면 적용
if (connection.default_body) {
updates.body = connection.default_body;
}
// 외부 커넥션 ID 저장 (백엔드에서 인증 정보 조회용)
updates.externalConnectionId = connection.id;
console.log("최종 업데이트:", updates);
onChange(updates);
@ -254,6 +263,19 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps
}
});
// 요청 메서드 결정
const requestMethod = dataSource.method || "GET";
// Request Body 파싱 (POST, PUT, PATCH인 경우)
let requestBody: any = undefined;
if (["POST", "PUT", "PATCH"].includes(requestMethod) && dataSource.body) {
try {
requestBody = JSON.parse(dataSource.body);
} catch {
throw new Error("Request Body가 올바른 JSON 형식이 아닙니다");
}
}
// 백엔드 프록시를 통한 외부 API 호출 (CORS 우회)
const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), {
method: "POST",
@ -262,9 +284,11 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps
},
body: JSON.stringify({
url: dataSource.endpoint,
method: "GET",
method: requestMethod,
headers: headers,
queryParams: params,
body: requestBody,
externalConnectionId: dataSource.externalConnectionId, // DB 토큰 등 인증 정보 조회용
}),
});
@ -314,10 +338,23 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps
if (dataSource.jsonPath) {
const paths = dataSource.jsonPath.split(".");
for (const path of paths) {
if (data && typeof data === "object" && path in data) {
data = data[path];
// 배열인 경우 인덱스 접근, 객체인 경우 키 접근
if (data === null || data === undefined) {
throw new Error(`JSON Path "${dataSource.jsonPath}"에서 데이터를 찾을 수 없습니다 (null/undefined)`);
}
if (Array.isArray(data)) {
// 배열인 경우 숫자 인덱스로 접근 시도
const index = parseInt(path);
if (!isNaN(index) && index >= 0 && index < data.length) {
data = data[index];
} else {
throw new Error(`JSON Path "${dataSource.jsonPath}"에서 배열 인덱스 "${path}"를 찾을 수 없습니다`);
}
} else if (typeof data === "object" && path in data) {
data = (data as Record<string, any>)[path];
} else {
throw new Error(`JSON Path "${dataSource.jsonPath}"에서 데이터를 찾을 수 없습니다`);
throw new Error(`JSON Path "${dataSource.jsonPath}"에서 "${path}" 키를 찾을 수 없습니다`);
}
}
}
@ -331,6 +368,16 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps
// 컬럼 추출 및 타입 분석
const firstRow = rows[0];
// firstRow가 null이거나 객체가 아닌 경우 처리
if (firstRow === null || firstRow === undefined) {
throw new Error("API 응답의 첫 번째 행이 비어있습니다");
}
if (typeof firstRow !== "object" || Array.isArray(firstRow)) {
throw new Error("API 응답 데이터가 올바른 객체 형식이 아닙니다");
}
const columns = Object.keys(firstRow);
// 각 컬럼의 타입 분석
@ -400,21 +447,54 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps
<p className="text-[11px] text-muted-foreground"> REST API </p>
</div>
{/* API URL */}
{/* HTTP 메서드 및 API URL */}
<div className="space-y-1.5">
<Label className="text-xs font-medium text-foreground">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"
/>
<div className="flex gap-2">
<Select
value={dataSource.method || "GET"}
onValueChange={(value) => onChange({ method: value as "GET" | "POST" | "PUT" | "PATCH" | "DELETE" })}
>
<SelectTrigger className="h-8 w-24 text-xs">
<SelectValue placeholder="GET" />
</SelectTrigger>
<SelectContent className="z-[9999]">
<SelectItem value="GET" className="text-xs">GET</SelectItem>
<SelectItem value="POST" className="text-xs">POST</SelectItem>
<SelectItem value="PUT" className="text-xs">PUT</SelectItem>
<SelectItem value="PATCH" className="text-xs">PATCH</SelectItem>
<SelectItem value="DELETE" className="text-xs">DELETE</SelectItem>
</SelectContent>
</Select>
<Input
type="url"
placeholder="https://api.example.com/data"
value={dataSource.endpoint || ""}
onChange={(e) => onChange({ endpoint: e.target.value })}
className="h-8 flex-1 text-xs"
/>
</div>
<p className="text-[11px] text-muted-foreground">
URL base_url ( base_url )
</p>
</div>
{/* Request Body (POST, PUT, PATCH인 경우) */}
{["POST", "PUT", "PATCH"].includes(dataSource.method || "") && (
<div className="space-y-1.5">
<Label className="text-xs font-medium text-foreground">Request Body (JSON)</Label>
<textarea
placeholder='{"key": "value"}'
value={dataSource.body || ""}
onChange={(e) => onChange({ body: e.target.value })}
className="h-24 w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-xs ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
/>
<p className="text-[11px] text-muted-foreground">
JSON
</p>
</div>
)}
{/* 쿼리 파라미터 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
@ -544,6 +624,30 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps
</p>
</div>
{/* 자동 새로고침 (HTTP Polling) */}
<div className="space-y-1.5">
<Label className="text-xs font-medium text-foreground"> </Label>
<Select
value={(dataSource.refreshInterval || 0).toString()}
onValueChange={(value) => onChange({ refreshInterval: parseInt(value) })}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="간격 선택" />
</SelectTrigger>
<SelectContent className="z-[9999]">
<SelectItem value="0" className="text-xs"> ()</SelectItem>
<SelectItem value="5" className="text-xs">5</SelectItem>
<SelectItem value="10" className="text-xs">10</SelectItem>
<SelectItem value="30" className="text-xs">30</SelectItem>
<SelectItem value="60" className="text-xs">1</SelectItem>
<SelectItem value="300" className="text-xs">5</SelectItem>
</SelectContent>
</Select>
<p className="text-[11px] text-muted-foreground">
API를
</p>
</div>
{/* 테스트 버튼 */}
<div className="flex justify-end">
<Button onClick={testApi} disabled={!dataSource.endpoint || testing}>

View File

@ -285,16 +285,46 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
});
}
// 요청 메서드 (기본값: GET)
const requestMethod = element.dataSource.method || "GET";
// 요청 body (POST, PUT, PATCH인 경우)
let requestBody = undefined;
if (["POST", "PUT", "PATCH"].includes(requestMethod) && element.dataSource.body) {
try {
requestBody = typeof element.dataSource.body === "string"
? JSON.parse(element.dataSource.body)
: element.dataSource.body;
} catch {
requestBody = element.dataSource.body;
}
}
// headers를 KeyValuePair[] 에서 객체로 변환
const headersObj: Record<string, string> = {};
if (element.dataSource.headers && Array.isArray(element.dataSource.headers)) {
element.dataSource.headers.forEach((h: any) => {
if (h.key && h.value) {
headersObj[h.key] = h.value;
}
});
} else if (element.dataSource.headers && typeof element.dataSource.headers === "object") {
Object.assign(headersObj, element.dataSource.headers);
}
const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), {
method: "POST",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
body: JSON.stringify({
url: element.dataSource.endpoint,
method: "GET",
headers: element.dataSource.headers || {},
method: requestMethod,
headers: headersObj,
queryParams: Object.fromEntries(params),
body: requestBody,
externalConnectionId: element.dataSource.externalConnectionId,
}),
});

View File

@ -316,12 +316,14 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
// 다중 데이터 소스 로딩
const loadMultipleDataSources = useCallback(async () => {
console.log("[ListTestWidget] dataSources:", dataSources);
if (!dataSources || dataSources.length === 0) {
// console.log("⚠️ 데이터 소스가 없습니다.");
console.log("[ListTestWidget] 데이터 소스가 없습니다.");
return;
}
// console.log(`🔄 ${dataSources.length}개의 데이터 소스 로딩 시작...`);
console.log(`[ListTestWidget] ${dataSources.length}개의 데이터 소스 로딩 시작...`, dataSources[0]);
setIsLoading(true);
setError(null);
@ -412,18 +414,52 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
});
}
// 요청 메서드 (기본값: GET)
const requestMethod = source.method || "GET";
// 요청 body (POST, PUT, PATCH인 경우)
let requestBody = undefined;
if (["POST", "PUT", "PATCH"].includes(requestMethod) && source.body) {
try {
// body가 문자열이면 JSON 파싱 시도
requestBody = typeof source.body === "string" ? JSON.parse(source.body) : source.body;
} catch {
// 파싱 실패하면 문자열 그대로 사용
requestBody = source.body;
}
}
// headers를 KeyValuePair[] 에서 객체로 변환
const headersObj: Record<string, string> = {};
if (source.headers && Array.isArray(source.headers)) {
source.headers.forEach((h: any) => {
if (h.key && h.value) {
headersObj[h.key] = h.value;
}
});
} else if (source.headers && typeof source.headers === "object") {
// 이미 객체인 경우 그대로 사용
Object.assign(headersObj, source.headers);
}
const requestPayload = {
url: source.endpoint,
method: requestMethod,
headers: headersObj,
queryParams: Object.fromEntries(params),
body: requestBody,
externalConnectionId: source.externalConnectionId,
};
console.log("[ListTestWidget] API 요청:", requestPayload);
const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), {
method: "POST",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
body: JSON.stringify({
url: source.endpoint,
method: "GET",
headers: source.headers || {},
queryParams: Object.fromEntries(params),
}),
body: JSON.stringify(requestPayload),
});
if (!response.ok) {