리스트 위젯 REST API 기능 개선
This commit is contained in:
parent
e84764dc2b
commit
bccb8a6330
|
|
@ -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"];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue