외부 REST API 커넥션 POST/Body + DB 토큰 테스트 지원

This commit is contained in:
dohyeons 2025-11-27 16:42:48 +09:00
parent 5b98819191
commit f3c5c90d7b
7 changed files with 469 additions and 62 deletions

42
PLAN.MD
View File

@ -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단계 긴급 버그 수정 완료 후 사용자 피드백 대기 중
- [완료] 모든 단계 구현 완료

View File

@ -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) {

View File

@ -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("올바르지 않은 인증 타입입니다.");

View File

@ -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 토큰" },
];

View File

@ -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">
릿 (, &#123;&#123;value&#125;&#125; )
</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입니다.

View File

@ -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>

View File

@ -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 토큰" },
];
}
}