외부 REST API 커넥션 POST/Body + DB 토큰 테스트 지원
This commit is contained in:
parent
5b98819191
commit
f3c5c90d7b
42
PLAN.MD
42
PLAN.MD
|
|
@ -1,28 +1,36 @@
|
|||
# 프로젝트: Digital Twin 에디터 안정화
|
||||
# 프로젝트: 외부 REST API 커넥션 관리 확장 (POST/Body 지원)
|
||||
|
||||
## 개요
|
||||
|
||||
Digital Twin 에디터(`DigitalTwinEditor.tsx`)에서 발생한 런타임 에러(`TypeError: Cannot read properties of undefined`)를 수정하고, 전반적인 안정성을 확보합니다.
|
||||
현재 GET 방식 위주로 구현된 외부 REST API 커넥션 관리 기능을 확장하여, POST, PUT, DELETE 등 다양한 HTTP 메서드와 JSON Request Body를 설정하고 테스트할 수 있도록 개선합니다. 이를 통해 토큰 발급 API나 데이터 전송 API 등 다양한 외부 시스템과의 연동을 지원합니다.
|
||||
|
||||
## 핵심 기능
|
||||
|
||||
1. `DigitalTwinEditor` 버그 수정
|
||||
2. 비동기 함수 입력값 유효성 검증 강화
|
||||
3. 외부 DB 연결 상태에 따른 방어 코드 추가
|
||||
1. **DB 스키마 확장**: `external_rest_api_connections` 테이블에 `default_method`, `default_body` 컬럼 추가
|
||||
2. **백엔드 로직 개선**:
|
||||
- 커넥션 생성/수정 시 메서드와 바디 정보 저장
|
||||
- 연결 테스트 시 설정된 메서드와 바디를 사용하여 요청 수행
|
||||
- SSL 인증서 검증 우회 옵션 적용 (내부망/테스트망 지원)
|
||||
3. **프론트엔드 UI 개선**:
|
||||
- 커넥션 설정 모달에 HTTP 메서드 선택(Select) 및 Body 입력(Textarea/JSON Editor) 필드 추가
|
||||
- 테스트 기능에서 Body 데이터 포함하여 요청 전송
|
||||
|
||||
## 테스트 계획
|
||||
### 1단계: 기본 기능 및 DB 마이그레이션
|
||||
- [x] DB 마이그레이션 스크립트 작성 및 실행
|
||||
- [x] 백엔드 타입 정의 수정 (`default_method`, `default_body` 추가)
|
||||
|
||||
### 1단계: 긴급 버그 수정
|
||||
### 2단계: 백엔드 로직 구현
|
||||
- [x] 커넥션 생성/수정 API 수정 (필드 추가)
|
||||
- [x] 커넥션 상세 조회 API 확인
|
||||
- [x] 연결 테스트 API 수정 (Method, Body 반영하여 요청 전송)
|
||||
|
||||
- [x] `loadMaterialCountsForLocations` 함수에서 `locaKeys` undefined 체크 추가 (완료)
|
||||
- [ ] 에디터 로드 및 객체 조작 시 에러 발생 여부 확인
|
||||
### 3단계: 프론트엔드 구현
|
||||
- [x] 커넥션 관리 리스트/모달 UI 수정
|
||||
- [x] 연결 테스트 UI 수정 및 기능 확인
|
||||
|
||||
### 2단계: 잠재적 문제 점검
|
||||
|
||||
- [ ] `loadLayout` 등 주요 로딩 함수의 데이터 유효성 검사
|
||||
- [ ] `handleToolDragStart`, `handleCanvasDrop` 등 인터랙션 함수의 예외 처리
|
||||
## 에러 처리 계획
|
||||
- **JSON 파싱 에러**: Body 입력값이 유효한 JSON이 아닐 경우 에러 처리
|
||||
- **API 호출 에러**: 외부 API 호출 실패 시 상세 로그 기록 및 클라이언트에 에러 메시지 전달
|
||||
- **SSL 인증 에러**: `rejectUnauthorized: false` 옵션으로 처리 (기존 `RestApiConnector` 활용)
|
||||
|
||||
## 진행 상태
|
||||
|
||||
- [진행중] 1단계 긴급 버그 수정 완료 후 사용자 피드백 대기 중
|
||||
|
||||
- [완료] 모든 단계 구현 완료
|
||||
|
|
|
|||
|
|
@ -213,7 +213,10 @@ router.post(
|
|||
}
|
||||
|
||||
const result =
|
||||
await ExternalRestApiConnectionService.testConnection(testRequest);
|
||||
await ExternalRestApiConnectionService.testConnection(
|
||||
testRequest,
|
||||
req.user?.companyCode
|
||||
);
|
||||
|
||||
return res.status(200).json(result);
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import { Pool, QueryResult } from "pg";
|
||||
import axios, { AxiosResponse } from "axios";
|
||||
import https from "https";
|
||||
import { getPool } from "../database/db";
|
||||
import logger from "../utils/logger";
|
||||
import {
|
||||
|
|
@ -30,6 +32,10 @@ export class ExternalRestApiConnectionService {
|
|||
let query = `
|
||||
SELECT
|
||||
id, connection_name, description, base_url, endpoint_path, default_headers,
|
||||
default_method,
|
||||
-- DB 스키마의 컬럼명은 default_request_body 기준이고
|
||||
-- 코드에서는 default_body 필드로 사용하기 위해 alias 처리
|
||||
default_request_body AS default_body,
|
||||
auth_type, auth_config, timeout, retry_count, retry_delay,
|
||||
company_code, is_active, created_date, created_by,
|
||||
updated_date, updated_by, last_test_date, last_test_result, last_test_message
|
||||
|
|
@ -129,6 +135,8 @@ export class ExternalRestApiConnectionService {
|
|||
let query = `
|
||||
SELECT
|
||||
id, connection_name, description, base_url, endpoint_path, default_headers,
|
||||
default_method,
|
||||
default_request_body AS default_body,
|
||||
auth_type, auth_config, timeout, retry_count, retry_delay,
|
||||
company_code, is_active, created_date, created_by,
|
||||
updated_date, updated_by, last_test_date, last_test_result, last_test_message
|
||||
|
|
@ -194,9 +202,10 @@ export class ExternalRestApiConnectionService {
|
|||
const query = `
|
||||
INSERT INTO external_rest_api_connections (
|
||||
connection_name, description, base_url, endpoint_path, default_headers,
|
||||
default_method, default_request_body,
|
||||
auth_type, auth_config, timeout, retry_count, retry_delay,
|
||||
company_code, is_active, created_by
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
|
|
@ -206,6 +215,8 @@ export class ExternalRestApiConnectionService {
|
|||
data.base_url,
|
||||
data.endpoint_path || null,
|
||||
JSON.stringify(data.default_headers || {}),
|
||||
data.default_method || "GET",
|
||||
data.default_body || null,
|
||||
data.auth_type,
|
||||
encryptedAuthConfig ? JSON.stringify(encryptedAuthConfig) : null,
|
||||
data.timeout || 30000,
|
||||
|
|
@ -301,6 +312,18 @@ export class ExternalRestApiConnectionService {
|
|||
paramIndex++;
|
||||
}
|
||||
|
||||
if (data.default_method !== undefined) {
|
||||
updateFields.push(`default_method = $${paramIndex}`);
|
||||
params.push(data.default_method);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (data.default_body !== undefined) {
|
||||
updateFields.push(`default_request_body = $${paramIndex}`);
|
||||
params.push(data.default_body);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (data.auth_type !== undefined) {
|
||||
updateFields.push(`auth_type = $${paramIndex}`);
|
||||
params.push(data.auth_type);
|
||||
|
|
@ -441,7 +464,8 @@ export class ExternalRestApiConnectionService {
|
|||
* REST API 연결 테스트 (테스트 요청 데이터 기반)
|
||||
*/
|
||||
static async testConnection(
|
||||
testRequest: RestApiTestRequest
|
||||
testRequest: RestApiTestRequest,
|
||||
userCompanyCode?: string
|
||||
): Promise<RestApiTestResult> {
|
||||
const startTime = Date.now();
|
||||
|
||||
|
|
@ -450,7 +474,78 @@ export class ExternalRestApiConnectionService {
|
|||
const headers = { ...testRequest.headers };
|
||||
|
||||
// 인증 헤더 추가
|
||||
if (
|
||||
if (testRequest.auth_type === "db-token") {
|
||||
const cfg = testRequest.auth_config || {};
|
||||
const {
|
||||
dbTableName,
|
||||
dbValueColumn,
|
||||
dbWhereColumn,
|
||||
dbWhereValue,
|
||||
dbHeaderName,
|
||||
dbHeaderTemplate,
|
||||
} = cfg;
|
||||
|
||||
if (!dbTableName || !dbValueColumn) {
|
||||
throw new Error("DB 토큰 설정이 올바르지 않습니다.");
|
||||
}
|
||||
|
||||
if (!userCompanyCode) {
|
||||
throw new Error("DB 토큰 모드에서는 회사 코드가 필요합니다.");
|
||||
}
|
||||
|
||||
const hasWhereColumn = !!dbWhereColumn;
|
||||
const hasWhereValue =
|
||||
dbWhereValue !== undefined && dbWhereValue !== null && dbWhereValue !== "";
|
||||
|
||||
// where 컬럼/값은 둘 다 비우거나 둘 다 채워야 함
|
||||
if (hasWhereColumn !== hasWhereValue) {
|
||||
throw new Error(
|
||||
"DB 토큰 설정에서 조건 컬럼과 조건 값은 둘 다 비우거나 둘 다 입력해야 합니다."
|
||||
);
|
||||
}
|
||||
|
||||
// 식별자 검증 (간단한 화이트리스트)
|
||||
const identifierRegex = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
||||
if (
|
||||
!identifierRegex.test(dbTableName) ||
|
||||
!identifierRegex.test(dbValueColumn) ||
|
||||
(hasWhereColumn && !identifierRegex.test(dbWhereColumn as string))
|
||||
) {
|
||||
throw new Error(
|
||||
"DB 토큰 설정에 유효하지 않은 테이블 또는 컬럼명이 포함되어 있습니다."
|
||||
);
|
||||
}
|
||||
|
||||
let sql = `
|
||||
SELECT ${dbValueColumn} AS token_value
|
||||
FROM ${dbTableName}
|
||||
WHERE company_code = $1
|
||||
`;
|
||||
|
||||
const params: any[] = [userCompanyCode];
|
||||
|
||||
if (hasWhereColumn && hasWhereValue) {
|
||||
sql += ` AND ${dbWhereColumn} = $2`;
|
||||
params.push(dbWhereValue);
|
||||
}
|
||||
|
||||
sql += `
|
||||
ORDER BY updated_date DESC
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
const tokenResult: QueryResult<any> = await pool.query(sql, params);
|
||||
|
||||
if (tokenResult.rowCount === 0) {
|
||||
throw new Error("DB에서 토큰을 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
const tokenValue = tokenResult.rows[0]["token_value"];
|
||||
const headerName = dbHeaderName || "Authorization";
|
||||
const template = dbHeaderTemplate || "Bearer {{value}}";
|
||||
|
||||
headers[headerName] = template.replace("{{value}}", tokenValue);
|
||||
} else if (
|
||||
testRequest.auth_type === "bearer" &&
|
||||
testRequest.auth_config?.token
|
||||
) {
|
||||
|
|
@ -493,25 +588,71 @@ export class ExternalRestApiConnectionService {
|
|||
`REST API 연결 테스트: ${testRequest.method || "GET"} ${url}`
|
||||
);
|
||||
|
||||
// HTTP 요청 실행
|
||||
const response = await fetch(url, {
|
||||
method: testRequest.method || "GET",
|
||||
headers,
|
||||
signal: AbortSignal.timeout(testRequest.timeout || 30000),
|
||||
});
|
||||
// Body 처리
|
||||
let body: any = undefined;
|
||||
if (testRequest.body) {
|
||||
// 이미 문자열이면 그대로, 객체면 JSON 문자열로 변환
|
||||
if (typeof testRequest.body === "string") {
|
||||
body = testRequest.body;
|
||||
} else {
|
||||
body = JSON.stringify(testRequest.body);
|
||||
}
|
||||
|
||||
const responseTime = Date.now() - startTime;
|
||||
let responseData = null;
|
||||
|
||||
try {
|
||||
responseData = await response.json();
|
||||
} catch {
|
||||
// JSON 파싱 실패는 무시 (텍스트 응답일 수 있음)
|
||||
// Content-Type 헤더가 없으면 기본적으로 application/json 추가
|
||||
const hasContentType = Object.keys(headers).some(
|
||||
(k) => k.toLowerCase() === "content-type"
|
||||
);
|
||||
if (!hasContentType) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
}
|
||||
}
|
||||
|
||||
// HTTP 요청 실행 (배치관리 RestApiConnector와 동일하게 TLS 검증 우회 옵션 적용)
|
||||
const httpsAgent = new https.Agent({
|
||||
// 배치관리와 동일하게, 일부 내부망/자체 서명 인증서를 사용하는 API를 위해
|
||||
// 인증서 검증을 비활성화한다.
|
||||
// 공개 인터넷용 API에는 신중히 사용해야 함.
|
||||
rejectUnauthorized: false,
|
||||
});
|
||||
|
||||
const requestConfig = {
|
||||
url,
|
||||
method: (testRequest.method || "GET") as any,
|
||||
headers,
|
||||
data: body,
|
||||
httpsAgent,
|
||||
timeout: testRequest.timeout || 30000,
|
||||
// 4xx/5xx 도 예외가 아니라 응답 객체로 처리
|
||||
validateStatus: () => true,
|
||||
};
|
||||
|
||||
// 요청 상세 로그 (민감 정보는 최소화)
|
||||
logger.info(
|
||||
`REST API 연결 테스트 요청 상세: ${JSON.stringify({
|
||||
method: requestConfig.method,
|
||||
url: requestConfig.url,
|
||||
headers: {
|
||||
...requestConfig.headers,
|
||||
// Authorization 헤더는 마스킹
|
||||
Authorization: requestConfig.headers?.Authorization
|
||||
? "***masked***"
|
||||
: undefined,
|
||||
},
|
||||
hasBody: !!body,
|
||||
})}`
|
||||
);
|
||||
|
||||
const response: AxiosResponse = await axios.request(requestConfig);
|
||||
|
||||
const responseTime = Date.now() - startTime;
|
||||
// axios는 response.data에 이미 파싱된 응답 본문을 담아준다.
|
||||
// JSON이 아니어도 그대로 내려보내서 프론트에서 확인할 수 있게 한다.
|
||||
const responseData = response.data ?? null;
|
||||
|
||||
return {
|
||||
success: response.ok,
|
||||
message: response.ok
|
||||
success: response.status >= 200 && response.status < 300,
|
||||
message:
|
||||
response.status >= 200 && response.status < 300
|
||||
? "연결 성공"
|
||||
: `연결 실패 (${response.status} ${response.statusText})`,
|
||||
response_time: responseTime,
|
||||
|
|
@ -552,17 +693,27 @@ export class ExternalRestApiConnectionService {
|
|||
|
||||
const connection = connectionResult.data;
|
||||
|
||||
// 리스트에서 endpoint를 넘기지 않으면,
|
||||
// 저장된 endpoint_path를 기본 엔드포인트로 사용
|
||||
const effectiveEndpoint =
|
||||
endpoint || connection.endpoint_path || undefined;
|
||||
|
||||
const testRequest: RestApiTestRequest = {
|
||||
id: connection.id,
|
||||
base_url: connection.base_url,
|
||||
endpoint,
|
||||
endpoint: effectiveEndpoint,
|
||||
method: (connection.default_method as any) || "GET", // 기본 메서드 적용
|
||||
headers: connection.default_headers,
|
||||
body: connection.default_body, // 기본 바디 적용
|
||||
auth_type: connection.auth_type,
|
||||
auth_config: connection.auth_config,
|
||||
timeout: connection.timeout,
|
||||
};
|
||||
|
||||
const result = await this.testConnection(testRequest);
|
||||
const result = await this.testConnection(
|
||||
testRequest,
|
||||
connection.company_code
|
||||
);
|
||||
|
||||
// 테스트 결과 저장
|
||||
await pool.query(
|
||||
|
|
@ -709,6 +860,7 @@ export class ExternalRestApiConnectionService {
|
|||
"bearer",
|
||||
"basic",
|
||||
"oauth2",
|
||||
"db-token",
|
||||
];
|
||||
if (!validAuthTypes.includes(data.auth_type)) {
|
||||
throw new Error("올바르지 않은 인증 타입입니다.");
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
// 외부 REST API 연결 관리 타입 정의
|
||||
|
||||
export type AuthType = "none" | "api-key" | "bearer" | "basic" | "oauth2";
|
||||
export type AuthType =
|
||||
| "none"
|
||||
| "api-key"
|
||||
| "bearer"
|
||||
| "basic"
|
||||
| "oauth2"
|
||||
| "db-token";
|
||||
|
||||
export interface ExternalRestApiConnection {
|
||||
id?: number;
|
||||
|
|
@ -9,6 +15,11 @@ export interface ExternalRestApiConnection {
|
|||
base_url: string;
|
||||
endpoint_path?: string;
|
||||
default_headers: Record<string, string>;
|
||||
|
||||
// 기본 메서드 및 바디 추가
|
||||
default_method?: string;
|
||||
default_body?: string;
|
||||
|
||||
auth_type: AuthType;
|
||||
auth_config?: {
|
||||
// API Key
|
||||
|
|
@ -28,6 +39,14 @@ export interface ExternalRestApiConnection {
|
|||
clientSecret?: string;
|
||||
tokenUrl?: string;
|
||||
accessToken?: string;
|
||||
|
||||
// DB 기반 토큰 모드
|
||||
dbTableName?: string;
|
||||
dbValueColumn?: string;
|
||||
dbWhereColumn?: string;
|
||||
dbWhereValue?: string;
|
||||
dbHeaderName?: string;
|
||||
dbHeaderTemplate?: string;
|
||||
};
|
||||
timeout?: number;
|
||||
retry_count?: number;
|
||||
|
|
@ -54,8 +73,9 @@ export interface RestApiTestRequest {
|
|||
id?: number;
|
||||
base_url: string;
|
||||
endpoint?: string;
|
||||
method?: "GET" | "POST" | "PUT" | "DELETE";
|
||||
method?: "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
|
||||
headers?: Record<string, string>;
|
||||
body?: any; // 테스트 요청 바디 추가
|
||||
auth_type?: AuthType;
|
||||
auth_config?: any;
|
||||
timeout?: number;
|
||||
|
|
@ -76,4 +96,5 @@ export const AUTH_TYPE_OPTIONS = [
|
|||
{ value: "bearer", label: "Bearer Token" },
|
||||
{ value: "basic", label: "Basic Auth" },
|
||||
{ value: "oauth2", label: "OAuth 2.0" },
|
||||
{ value: "db-token", label: "DB 토큰" },
|
||||
];
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ export function AuthenticationConfig({
|
|||
<SelectItem value="bearer">Bearer Token</SelectItem>
|
||||
<SelectItem value="basic">Basic Auth</SelectItem>
|
||||
<SelectItem value="oauth2">OAuth 2.0</SelectItem>
|
||||
<SelectItem value="db-token">DB 토큰</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
|
@ -192,6 +193,94 @@ export function AuthenticationConfig({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{authType === "db-token" && (
|
||||
<div className="space-y-4 rounded-md border bg-gray-50 p-4">
|
||||
<h4 className="text-sm font-medium">DB 기반 토큰 설정</h4>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="db-table-name">테이블명</Label>
|
||||
<Input
|
||||
id="db-table-name"
|
||||
type="text"
|
||||
value={authConfig.dbTableName || ""}
|
||||
onChange={(e) => updateAuthConfig("dbTableName", e.target.value)}
|
||||
placeholder="예: auth_tokens"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="db-value-column">값 컬럼명</Label>
|
||||
<Input
|
||||
id="db-value-column"
|
||||
type="text"
|
||||
value={authConfig.dbValueColumn || ""}
|
||||
onChange={(e) =>
|
||||
updateAuthConfig("dbValueColumn", e.target.value)
|
||||
}
|
||||
placeholder="예: access_token"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="db-where-column">조건 컬럼명</Label>
|
||||
<Input
|
||||
id="db-where-column"
|
||||
type="text"
|
||||
value={authConfig.dbWhereColumn || ""}
|
||||
onChange={(e) =>
|
||||
updateAuthConfig("dbWhereColumn", e.target.value)
|
||||
}
|
||||
placeholder="예: service_name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="db-where-value">조건 값</Label>
|
||||
<Input
|
||||
id="db-where-value"
|
||||
type="text"
|
||||
value={authConfig.dbWhereValue || ""}
|
||||
onChange={(e) =>
|
||||
updateAuthConfig("dbWhereValue", e.target.value)
|
||||
}
|
||||
placeholder="예: kakao"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="db-header-name">헤더 이름 (선택)</Label>
|
||||
<Input
|
||||
id="db-header-name"
|
||||
type="text"
|
||||
value={authConfig.dbHeaderName || ""}
|
||||
onChange={(e) =>
|
||||
updateAuthConfig("dbHeaderName", e.target.value)
|
||||
}
|
||||
placeholder="기본값: Authorization"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="db-header-template">
|
||||
헤더 템플릿 (선택, {{value}} 치환)
|
||||
</Label>
|
||||
<Input
|
||||
id="db-header-template"
|
||||
type="text"
|
||||
value={authConfig.dbHeaderTemplate || ""}
|
||||
onChange={(e) =>
|
||||
updateAuthConfig("dbHeaderTemplate", e.target.value)
|
||||
}
|
||||
placeholder='기본값: "Bearer {{value}}"'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-500">
|
||||
company_code는 현재 로그인한 사용자의 회사 코드로 자동 필터링됩니다.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{authType === "none" && (
|
||||
<div className="rounded-md border border-dashed p-4 text-center text-sm text-gray-500">
|
||||
인증이 필요하지 않은 공개 API입니다.
|
||||
|
|
|
|||
|
|
@ -21,10 +21,13 @@ import {
|
|||
ExternalRestApiConnection,
|
||||
AuthType,
|
||||
RestApiTestResult,
|
||||
RestApiTestRequest,
|
||||
} from "@/lib/api/externalRestApiConnection";
|
||||
import { HeadersManager } from "./HeadersManager";
|
||||
import { AuthenticationConfig } from "./AuthenticationConfig";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
|
||||
interface RestApiConnectionModalProps {
|
||||
isOpen: boolean;
|
||||
|
|
@ -42,6 +45,8 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
|||
const [baseUrl, setBaseUrl] = useState("");
|
||||
const [endpointPath, setEndpointPath] = useState("");
|
||||
const [defaultHeaders, setDefaultHeaders] = useState<Record<string, string>>({});
|
||||
const [defaultMethod, setDefaultMethod] = useState("GET");
|
||||
const [defaultBody, setDefaultBody] = useState("");
|
||||
const [authType, setAuthType] = useState<AuthType>("none");
|
||||
const [authConfig, setAuthConfig] = useState<any>({});
|
||||
const [timeout, setTimeout] = useState(30000);
|
||||
|
|
@ -52,6 +57,8 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
|||
// UI 상태
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [testEndpoint, setTestEndpoint] = useState("");
|
||||
const [testMethod, setTestMethod] = useState("GET");
|
||||
const [testBody, setTestBody] = useState("");
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<RestApiTestResult | null>(null);
|
||||
const [testRequestUrl, setTestRequestUrl] = useState<string>("");
|
||||
|
|
@ -65,12 +72,19 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
|||
setBaseUrl(connection.base_url);
|
||||
setEndpointPath(connection.endpoint_path || "");
|
||||
setDefaultHeaders(connection.default_headers || {});
|
||||
setDefaultMethod(connection.default_method || "GET");
|
||||
setDefaultBody(connection.default_body || "");
|
||||
setAuthType(connection.auth_type);
|
||||
setAuthConfig(connection.auth_config || {});
|
||||
setTimeout(connection.timeout || 30000);
|
||||
setRetryCount(connection.retry_count || 0);
|
||||
setRetryDelay(connection.retry_delay || 1000);
|
||||
setIsActive(connection.is_active === "Y");
|
||||
|
||||
// 테스트 초기값 설정
|
||||
setTestEndpoint("");
|
||||
setTestMethod(connection.default_method || "GET");
|
||||
setTestBody(connection.default_body || "");
|
||||
} else {
|
||||
// 초기화
|
||||
setConnectionName("");
|
||||
|
|
@ -78,16 +92,22 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
|||
setBaseUrl("");
|
||||
setEndpointPath("");
|
||||
setDefaultHeaders({ "Content-Type": "application/json" });
|
||||
setDefaultMethod("GET");
|
||||
setDefaultBody("");
|
||||
setAuthType("none");
|
||||
setAuthConfig({});
|
||||
setTimeout(30000);
|
||||
setRetryCount(0);
|
||||
setRetryDelay(1000);
|
||||
setIsActive(true);
|
||||
|
||||
// 테스트 초기값 설정
|
||||
setTestEndpoint("");
|
||||
setTestMethod("GET");
|
||||
setTestBody("");
|
||||
}
|
||||
|
||||
setTestResult(null);
|
||||
setTestEndpoint("");
|
||||
setTestRequestUrl("");
|
||||
}, [connection, isOpen]);
|
||||
|
||||
|
|
@ -111,14 +131,18 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
|||
setTestRequestUrl(fullUrl);
|
||||
|
||||
try {
|
||||
const result = await ExternalRestApiConnectionAPI.testConnection({
|
||||
const testRequest: RestApiTestRequest = {
|
||||
base_url: baseUrl,
|
||||
endpoint: testEndpoint || undefined,
|
||||
method: testMethod as any,
|
||||
headers: defaultHeaders,
|
||||
body: testBody ? JSON.parse(testBody) : undefined,
|
||||
auth_type: authType,
|
||||
auth_config: authConfig,
|
||||
timeout,
|
||||
});
|
||||
};
|
||||
|
||||
const result = await ExternalRestApiConnectionAPI.testConnection(testRequest);
|
||||
|
||||
setTestResult(result);
|
||||
|
||||
|
|
@ -178,6 +202,20 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
|||
return;
|
||||
}
|
||||
|
||||
// JSON 유효성 검증
|
||||
if (defaultBody && defaultMethod !== "GET" && defaultMethod !== "DELETE") {
|
||||
try {
|
||||
JSON.parse(defaultBody);
|
||||
} catch {
|
||||
toast({
|
||||
title: "입력 오류",
|
||||
description: "기본 Body가 올바른 JSON 형식이 아닙니다.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
|
|
@ -187,6 +225,8 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
|||
base_url: baseUrl,
|
||||
endpoint_path: endpointPath || undefined,
|
||||
default_headers: defaultHeaders,
|
||||
default_method: defaultMethod,
|
||||
default_body: defaultBody || undefined,
|
||||
auth_type: authType,
|
||||
auth_config: authType === "none" ? undefined : authConfig,
|
||||
timeout,
|
||||
|
|
@ -262,12 +302,28 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
|||
<Label htmlFor="base-url">
|
||||
기본 URL <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="base-url"
|
||||
value={baseUrl}
|
||||
onChange={(e) => setBaseUrl(e.target.value)}
|
||||
placeholder="https://api.example.com"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Select value={defaultMethod} onValueChange={setDefaultMethod}>
|
||||
<SelectTrigger className="w-[100px]">
|
||||
<SelectValue placeholder="Method" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="GET">GET</SelectItem>
|
||||
<SelectItem value="POST">POST</SelectItem>
|
||||
<SelectItem value="PUT">PUT</SelectItem>
|
||||
<SelectItem value="DELETE">DELETE</SelectItem>
|
||||
<SelectItem value="PATCH">PATCH</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
id="base-url"
|
||||
value={baseUrl}
|
||||
onChange={(e) => setBaseUrl(e.target.value)}
|
||||
placeholder="https://api.example.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
도메인 부분만 입력하세요 (예: https://apihub.kma.go.kr)
|
||||
</p>
|
||||
|
|
@ -286,6 +342,21 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
|||
</p>
|
||||
</div>
|
||||
|
||||
{/* 기본 Body (POST, PUT, PATCH일 때만 표시) */}
|
||||
{(defaultMethod === "POST" || defaultMethod === "PUT" || defaultMethod === "PATCH") && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="default-body">기본 Request Body (JSON)</Label>
|
||||
<Textarea
|
||||
id="default-body"
|
||||
value={defaultBody}
|
||||
onChange={(e) => setDefaultBody(e.target.value)}
|
||||
placeholder='{"key": "value"}'
|
||||
className="font-mono text-xs"
|
||||
rows={5}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch id="is-active" checked={isActive} onCheckedChange={setIsActive} />
|
||||
<Label htmlFor="is-active" className="cursor-pointer">
|
||||
|
|
@ -370,13 +441,45 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
|||
<h3 className="text-sm font-semibold">연결 테스트</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="test-endpoint">테스트 엔드포인트 (선택)</Label>
|
||||
<Input
|
||||
id="test-endpoint"
|
||||
value={testEndpoint}
|
||||
onChange={(e) => setTestEndpoint(e.target.value)}
|
||||
placeholder="엔드포인트 또는 빈칸(기본 URL만 테스트)"
|
||||
/>
|
||||
<Label htmlFor="test-endpoint">테스트 설정</Label>
|
||||
<div className="flex gap-2 mb-2">
|
||||
<Select value={testMethod} onValueChange={setTestMethod}>
|
||||
<SelectTrigger className="w-[100px]">
|
||||
<SelectValue placeholder="Method" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="GET">GET</SelectItem>
|
||||
<SelectItem value="POST">POST</SelectItem>
|
||||
<SelectItem value="PUT">PUT</SelectItem>
|
||||
<SelectItem value="DELETE">DELETE</SelectItem>
|
||||
<SelectItem value="PATCH">PATCH</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
id="test-endpoint"
|
||||
value={testEndpoint}
|
||||
onChange={(e) => setTestEndpoint(e.target.value)}
|
||||
placeholder="엔드포인트 (예: /users/1)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(testMethod === "POST" || testMethod === "PUT" || testMethod === "PATCH") && (
|
||||
<div className="mt-2">
|
||||
<Label htmlFor="test-body" className="text-xs text-muted-foreground mb-1 block">
|
||||
Test Request Body (JSON)
|
||||
</Label>
|
||||
<Textarea
|
||||
id="test-body"
|
||||
value={testBody}
|
||||
onChange={(e) => setTestBody(e.target.value)}
|
||||
placeholder='{"test": "data"}'
|
||||
className="font-mono text-xs"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button type="button" variant="outline" onClick={handleTest} disabled={testing}>
|
||||
|
|
@ -388,10 +491,22 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
|||
{testRequestUrl && (
|
||||
<div className="bg-muted/30 space-y-3 rounded-md border p-3">
|
||||
<div>
|
||||
<div className="text-muted-foreground mb-1 text-xs font-medium">테스트 요청 URL</div>
|
||||
<code className="text-foreground block text-xs break-all">GET {testRequestUrl}</code>
|
||||
<div className="text-muted-foreground mb-1 text-xs font-medium">테스트 요청</div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Badge variant="outline">{testMethod}</Badge>
|
||||
<code className="text-foreground text-xs break-all">{testRequestUrl}</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{testBody && (testMethod === "POST" || testMethod === "PUT" || testMethod === "PATCH") && (
|
||||
<div>
|
||||
<div className="text-muted-foreground mb-1 text-xs font-medium">Request Body</div>
|
||||
<pre className="bg-muted p-2 rounded text-xs overflow-auto max-h-[100px]">
|
||||
{testBody}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{Object.keys(defaultHeaders).length > 0 && (
|
||||
<div>
|
||||
<div className="text-muted-foreground mb-1 text-xs font-medium">요청 헤더</div>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { apiClient } from "./client";
|
||||
|
||||
export type AuthType = "none" | "api-key" | "bearer" | "basic" | "oauth2";
|
||||
export type AuthType = "none" | "api-key" | "bearer" | "basic" | "oauth2" | "db-token";
|
||||
|
||||
export interface ExternalRestApiConnection {
|
||||
id?: number;
|
||||
|
|
@ -11,18 +11,34 @@ export interface ExternalRestApiConnection {
|
|||
base_url: string;
|
||||
endpoint_path?: string;
|
||||
default_headers: Record<string, string>;
|
||||
// 기본 메서드 및 바디 추가
|
||||
default_method?: string;
|
||||
default_body?: string;
|
||||
|
||||
auth_type: AuthType;
|
||||
auth_config?: {
|
||||
// API Key
|
||||
keyLocation?: "header" | "query";
|
||||
keyName?: string;
|
||||
keyValue?: string;
|
||||
// Bearer Token
|
||||
token?: string;
|
||||
// Basic Auth
|
||||
username?: string;
|
||||
password?: string;
|
||||
// OAuth2
|
||||
clientId?: string;
|
||||
clientSecret?: string;
|
||||
tokenUrl?: string;
|
||||
accessToken?: string;
|
||||
|
||||
// DB 기반 토큰 모드
|
||||
dbTableName?: string;
|
||||
dbValueColumn?: string;
|
||||
dbWhereColumn?: string;
|
||||
dbWhereValue?: string;
|
||||
dbHeaderName?: string;
|
||||
dbHeaderTemplate?: string;
|
||||
};
|
||||
timeout?: number;
|
||||
retry_count?: number;
|
||||
|
|
@ -49,9 +65,11 @@ export interface RestApiTestRequest {
|
|||
id?: number;
|
||||
base_url: string;
|
||||
endpoint?: string;
|
||||
method?: "GET" | "POST" | "PUT" | "DELETE";
|
||||
method?: "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
|
||||
headers?: Record<string, string>;
|
||||
body?: unknown; // 테스트 요청 바디 추가
|
||||
auth_type?: AuthType;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
auth_config?: any;
|
||||
timeout?: number;
|
||||
}
|
||||
|
|
@ -61,7 +79,7 @@ export interface RestApiTestResult {
|
|||
message: string;
|
||||
response_time?: number;
|
||||
status_code?: number;
|
||||
response_data?: any;
|
||||
response_data?: unknown;
|
||||
error_details?: string;
|
||||
}
|
||||
|
||||
|
|
@ -71,7 +89,7 @@ export interface ApiResponse<T> {
|
|||
message?: string;
|
||||
error?: {
|
||||
code: string;
|
||||
details?: any;
|
||||
details?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -184,6 +202,7 @@ export class ExternalRestApiConnectionAPI {
|
|||
{ value: "bearer", label: "Bearer Token" },
|
||||
{ value: "basic", label: "Basic Auth" },
|
||||
{ value: "oauth2", label: "OAuth 2.0" },
|
||||
{ value: "db-token", label: "DB 토큰" },
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue