generated from gc/template-java-maven
Merge pull request 'release: 2026-04-14 (15건 커밋)' (#45) from develop into main
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 59s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 59s
This commit is contained in:
커밋
3b5c4de67c
@ -6,6 +6,29 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/ko/1.0.0/).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [2026-04-14]
|
||||||
|
|
||||||
|
### 추가
|
||||||
|
|
||||||
|
- S&P API HUB SPA 대시보드 (배너, 인기 API, 최신 API, 서비스 카드) (#40)
|
||||||
|
- API Hub 서비스 트리 사이드바 레이아웃 (서비스 > 도메인 > API) (#40)
|
||||||
|
- 서비스별 API 목록 페이지 (도메인별 그룹) (#40)
|
||||||
|
- API 상세 명세 페이지 (#40)
|
||||||
|
- 백엔드 카탈로그/최신 API 조회 엔드포인트 (#40)
|
||||||
|
- API 관리 상세 화면 (Spec/Param CRUD, 출력결과 JSON 파싱) (#42)
|
||||||
|
- 시스템 공통 설정 관리 (SnpSystemConfig, 공통 샘플 코드) (#42)
|
||||||
|
- API HUB 상세 화면 개선 (아코디언, 샘플 URL, 출력결과 2열) (#42)
|
||||||
|
- Gateway API 인증: X-API-KEY 헤더 → authKey 쿼리 파라미터 변경 (#42)
|
||||||
|
- 일일 요청량 제한 기능 (daily_request_limit, HTTP 429) (#42)
|
||||||
|
- 에러 응답에 code 필드 추가, 인증/권한 거부 로그 DENIED 분리 (#42)
|
||||||
|
- API Key 검토 모달 예상 요청량 수정 기능 (#42)
|
||||||
|
- 도메인 관리 (SnpApiDomain CRUD, SVG 아이콘, 정렬순서) (#42)
|
||||||
|
- API HUB 사이드바: 서비스 기반 → 도메인 기반 플랫 메뉴 변경 (#42)
|
||||||
|
- 도메인 상세 페이지 (API 리스트 뷰, 검색) (#42)
|
||||||
|
- API 사용 신청 모달 (API HUB 상세 화면 내 도메인 기반 체크박스 선택) (#42)
|
||||||
|
- API 선택 UI: 서비스 기반 → 도메인 기반 변경 (Path/Method 제거) (#42)
|
||||||
|
- 대시보드 개선: 도메인 이미지 카드, 인기 API 주간 Top 3, 랭킹 뱃지 (#42)
|
||||||
|
|
||||||
## [2026-04-13]
|
## [2026-04-13]
|
||||||
|
|
||||||
### 추가
|
### 추가
|
||||||
|
|||||||
BIN
frontend/public/images/domains/ais.jpg
Normal file
BIN
frontend/public/images/domains/ais.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | 크기: 658 KiB |
BIN
frontend/public/images/domains/company.jpg
Normal file
BIN
frontend/public/images/domains/company.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | 크기: 1.8 MiB |
BIN
frontend/public/images/domains/compliance.jpg
Normal file
BIN
frontend/public/images/domains/compliance.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | 크기: 4.0 MiB |
BIN
frontend/public/images/domains/risk.jpg
Normal file
BIN
frontend/public/images/domains/risk.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | 크기: 2.7 MiB |
BIN
frontend/public/images/domains/ship.jpg
Normal file
BIN
frontend/public/images/domains/ship.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | 크기: 4.0 MiB |
@ -11,6 +11,10 @@ import MyKeysPage from './pages/apikeys/MyKeysPage';
|
|||||||
import KeyRequestPage from './pages/apikeys/KeyRequestPage';
|
import KeyRequestPage from './pages/apikeys/KeyRequestPage';
|
||||||
import KeyAdminPage from './pages/apikeys/KeyAdminPage';
|
import KeyAdminPage from './pages/apikeys/KeyAdminPage';
|
||||||
import ServicesPage from './pages/admin/ServicesPage';
|
import ServicesPage from './pages/admin/ServicesPage';
|
||||||
|
import DomainsPage from './pages/admin/DomainsPage';
|
||||||
|
import ApisPage from './pages/admin/ApisPage';
|
||||||
|
import ApiEditPage from './pages/admin/ApiEditPage';
|
||||||
|
import SampleCodePage from './pages/admin/SampleCodePage';
|
||||||
import UsersPage from './pages/admin/UsersPage';
|
import UsersPage from './pages/admin/UsersPage';
|
||||||
import TenantsPage from './pages/admin/TenantsPage';
|
import TenantsPage from './pages/admin/TenantsPage';
|
||||||
import ServiceStatsPage from './pages/statistics/ServiceStatsPage';
|
import ServiceStatsPage from './pages/statistics/ServiceStatsPage';
|
||||||
@ -18,6 +22,11 @@ import UserStatsPage from './pages/statistics/UserStatsPage';
|
|||||||
import ApiStatsPage from './pages/statistics/ApiStatsPage';
|
import ApiStatsPage from './pages/statistics/ApiStatsPage';
|
||||||
import TenantStatsPage from './pages/statistics/TenantStatsPage';
|
import TenantStatsPage from './pages/statistics/TenantStatsPage';
|
||||||
import UsageTrendPage from './pages/statistics/UsageTrendPage';
|
import UsageTrendPage from './pages/statistics/UsageTrendPage';
|
||||||
|
import ApiHubLayout from './layouts/ApiHubLayout';
|
||||||
|
import ApiHubDashboardPage from './pages/apihub/ApiHubDashboardPage';
|
||||||
|
import ApiHubServicePage from './pages/apihub/ApiHubServicePage';
|
||||||
|
import ApiHubApiDetailPage from './pages/apihub/ApiHubApiDetailPage';
|
||||||
|
import ApiHubDomainPage from './pages/apihub/ApiHubDomainPage';
|
||||||
import NotFoundPage from './pages/NotFoundPage';
|
import NotFoundPage from './pages/NotFoundPage';
|
||||||
import RoleGuard from './components/RoleGuard';
|
import RoleGuard from './components/RoleGuard';
|
||||||
|
|
||||||
@ -45,10 +54,20 @@ const App = () => {
|
|||||||
<Route path="/apikeys/request" element={<KeyRequestPage />} />
|
<Route path="/apikeys/request" element={<KeyRequestPage />} />
|
||||||
<Route path="/apikeys/admin" element={<RoleGuard allowedRoles={['ADMIN', 'MANAGER']}><KeyAdminPage /></RoleGuard>} />
|
<Route path="/apikeys/admin" element={<RoleGuard allowedRoles={['ADMIN', 'MANAGER']}><KeyAdminPage /></RoleGuard>} />
|
||||||
<Route path="/admin/services" element={<RoleGuard allowedRoles={['ADMIN']}><ServicesPage /></RoleGuard>} />
|
<Route path="/admin/services" element={<RoleGuard allowedRoles={['ADMIN']}><ServicesPage /></RoleGuard>} />
|
||||||
|
<Route path="/admin/domains" element={<RoleGuard allowedRoles={['ADMIN']}><DomainsPage /></RoleGuard>} />
|
||||||
|
<Route path="/admin/apis" element={<RoleGuard allowedRoles={['ADMIN']}><ApisPage /></RoleGuard>} />
|
||||||
|
<Route path="/admin/apis/:serviceId/:apiId" element={<RoleGuard allowedRoles={['ADMIN']}><ApiEditPage /></RoleGuard>} />
|
||||||
|
<Route path="/admin/sample-code" element={<RoleGuard allowedRoles={['ADMIN']}><SampleCodePage /></RoleGuard>} />
|
||||||
<Route path="/admin/users" element={<RoleGuard allowedRoles={['ADMIN']}><UsersPage /></RoleGuard>} />
|
<Route path="/admin/users" element={<RoleGuard allowedRoles={['ADMIN']}><UsersPage /></RoleGuard>} />
|
||||||
<Route path="/admin/tenants" element={<RoleGuard allowedRoles={['ADMIN']}><TenantsPage /></RoleGuard>} />
|
<Route path="/admin/tenants" element={<RoleGuard allowedRoles={['ADMIN']}><TenantsPage /></RoleGuard>} />
|
||||||
<Route path="*" element={<NotFoundPage />} />
|
<Route path="*" element={<NotFoundPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
<Route element={<ApiHubLayout />}>
|
||||||
|
<Route path="/api-hub" element={<ApiHubDashboardPage />} />
|
||||||
|
<Route path="/api-hub/domains/:domainName" element={<ApiHubDomainPage />} />
|
||||||
|
<Route path="/api-hub/services/:serviceId" element={<ApiHubServicePage />} />
|
||||||
|
<Route path="/api-hub/services/:serviceId/apis/:apiId" element={<ApiHubApiDetailPage />} />
|
||||||
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
|||||||
335
frontend/src/layouts/ApiHubLayout.tsx
Normal file
335
frontend/src/layouts/ApiHubLayout.tsx
Normal file
@ -0,0 +1,335 @@
|
|||||||
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
|
import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../hooks/useAuth';
|
||||||
|
import { useTheme } from '../hooks/useTheme';
|
||||||
|
import { getCatalog } from '../services/apiHubService';
|
||||||
|
import type { ServiceCatalog } from '../types/apihub';
|
||||||
|
|
||||||
|
const ROLES = ['ADMIN', 'MANAGER', 'USER'] as const;
|
||||||
|
|
||||||
|
const DEFAULT_ICON_PATHS = ['M3.75 9.776c.112-.017.227-.026.344-.026h15.812c.117 0 .232.009.344.026m-16.5 0a2.25 2.25 0 00-1.883 2.542l.857 6a2.25 2.25 0 002.227 1.932H19.05a2.25 2.25 0 002.227-1.932l.857-6a2.25 2.25 0 00-1.883-2.542m-16.5 0V6A2.25 2.25 0 016 3.75h3.879a1.5 1.5 0 011.06.44l2.122 2.12a1.5 1.5 0 001.06.44H18A2.25 2.25 0 0120.25 9v.776'];
|
||||||
|
|
||||||
|
/** iconPath 문자열에서 SVG path d 값 배열을 추출 */
|
||||||
|
const parseIconPaths = (iconPath: string | null): string[] => {
|
||||||
|
if (!iconPath) return DEFAULT_ICON_PATHS;
|
||||||
|
// <path d="..."/> 형태에서 d 값 추출
|
||||||
|
const pathRegex = /d="([^"]+)"/g;
|
||||||
|
const matches: string[] = [];
|
||||||
|
let m;
|
||||||
|
while ((m = pathRegex.exec(iconPath)) !== null) {
|
||||||
|
matches.push(m[1]);
|
||||||
|
}
|
||||||
|
// d 태그가 없으면 단일 path d 값으로 간주
|
||||||
|
return matches.length > 0 ? matches : [iconPath];
|
||||||
|
};
|
||||||
|
|
||||||
|
interface FlatDomainGroup {
|
||||||
|
domain: string;
|
||||||
|
iconPath: string | null;
|
||||||
|
sortOrder: number;
|
||||||
|
apis: { serviceId: number; apiId: number; apiName: string; apiPath: string; apiMethod: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const ApiHubLayout = () => {
|
||||||
|
const { user, setRole } = useAuth();
|
||||||
|
const { theme, toggleTheme } = useTheme();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
const [catalog, setCatalog] = useState<ServiceCatalog[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [openDomains, setOpenDomains] = useState<Record<string, boolean>>({});
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getCatalog()
|
||||||
|
.then((res) => {
|
||||||
|
const items = res.data ?? [];
|
||||||
|
setCatalog(items);
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleDomain = (key: string) => {
|
||||||
|
setOpenDomains((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 서비스 계층을 제거하고 도메인 기준으로 플랫하게 재그룹핑
|
||||||
|
const domainGroups = useMemo<FlatDomainGroup[]>(() => {
|
||||||
|
const map = new Map<string, { apis: FlatDomainGroup['apis']; iconPath: string | null; sortOrder: number }>();
|
||||||
|
for (const svc of catalog) {
|
||||||
|
for (const dg of svc.domains) {
|
||||||
|
const key = dg.domain.toUpperCase();
|
||||||
|
const existing = map.get(key);
|
||||||
|
const apis = existing?.apis ?? [];
|
||||||
|
apis.push(
|
||||||
|
...dg.apis.map((api) => ({
|
||||||
|
serviceId: svc.serviceId,
|
||||||
|
apiId: api.apiId,
|
||||||
|
apiName: api.apiName,
|
||||||
|
apiPath: api.apiPath,
|
||||||
|
apiMethod: api.apiMethod,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
// 첫 번째로 발견된 iconPath/sortOrder 사용
|
||||||
|
const iconPath = existing?.iconPath !== undefined ? existing.iconPath : (dg.iconPath ?? null);
|
||||||
|
const sortOrder = existing?.sortOrder !== undefined ? existing.sortOrder : (dg.sortOrder ?? Number.MAX_SAFE_INTEGER);
|
||||||
|
map.set(key, { apis, iconPath, sortOrder });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(map.entries())
|
||||||
|
.map(([domain, { apis, iconPath, sortOrder }]) => ({ domain, iconPath, sortOrder, apis }))
|
||||||
|
.sort((a, b) => a.sortOrder - b.sortOrder || a.domain.localeCompare(b.domain));
|
||||||
|
}, [catalog]);
|
||||||
|
|
||||||
|
const filteredDomainGroups = useMemo(() => {
|
||||||
|
if (!searchQuery.trim()) return domainGroups;
|
||||||
|
const q = searchQuery.trim().toLowerCase();
|
||||||
|
return domainGroups
|
||||||
|
.map((dg) => {
|
||||||
|
const domainMatch = dg.domain.toLowerCase().includes(q);
|
||||||
|
const filteredApis = dg.apis.filter(
|
||||||
|
(api) => api.apiName.toLowerCase().includes(q) || api.apiPath.toLowerCase().includes(q),
|
||||||
|
);
|
||||||
|
if (domainMatch) return dg;
|
||||||
|
if (filteredApis.length > 0) return { ...dg, apis: filteredApis };
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
.filter((dg): dg is NonNullable<typeof dg> => dg !== null);
|
||||||
|
}, [domainGroups, searchQuery]);
|
||||||
|
|
||||||
|
const isSearching = searchQuery.trim().length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen">
|
||||||
|
{/* Sidebar */}
|
||||||
|
<aside className="fixed left-0 top-0 h-screen w-72 bg-gray-900 text-white flex flex-col">
|
||||||
|
{/* Sidebar header */}
|
||||||
|
<div className="flex-shrink-0 border-b border-gray-700">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/api-hub')}
|
||||||
|
className="flex items-center gap-2 px-5 h-16 w-full hover:bg-gray-800 transition-colors"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="h-5 w-5 text-blue-400 flex-shrink-0"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className="text-base font-bold tracking-wide text-white">S&P API HUB</span>
|
||||||
|
</button>
|
||||||
|
<div className="px-5 pb-3">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/dashboard')}
|
||||||
|
className="flex items-center gap-1.5 text-xs text-gray-400 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
Back to Dashboard
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div className="flex-shrink-0 px-3 pt-3 pb-1">
|
||||||
|
<div className="relative">
|
||||||
|
<svg
|
||||||
|
className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-gray-400 pointer-events-none"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder="API 검색..."
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 text-gray-200 placeholder-gray-500 rounded-lg pl-8 pr-8 py-1.5 text-xs focus:ring-1 focus:ring-blue-500 focus:border-blue-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
{searchQuery && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSearchQuery('')}
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300"
|
||||||
|
>
|
||||||
|
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation tree */}
|
||||||
|
<nav className="flex-1 overflow-y-auto px-3 py-2 space-y-1">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-10">
|
||||||
|
<svg
|
||||||
|
className="h-6 w-6 animate-spin text-gray-400"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
className="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
) : filteredDomainGroups.length === 0 ? (
|
||||||
|
<p className="text-xs text-gray-500 text-center py-6">검색 결과가 없습니다</p>
|
||||||
|
) : (
|
||||||
|
filteredDomainGroups.map((dg) => {
|
||||||
|
const domainOpen = isSearching || (openDomains[dg.domain] ?? true);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={dg.domain}>
|
||||||
|
{/* Domain header */}
|
||||||
|
<div className="flex w-full items-center gap-1 rounded-lg text-sm font-semibold text-gray-200 hover:bg-gray-800 hover:text-white transition-colors">
|
||||||
|
{/* 도메인명 클릭 → 도메인 상세 페이지 이동 */}
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/api-hub/domains/${encodeURIComponent(dg.domain)}`)}
|
||||||
|
className="flex flex-1 items-center gap-2 px-3 py-2 min-w-0"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="h-4 w-4 flex-shrink-0 text-gray-400"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
{parseIconPaths(dg.iconPath).map((d, i) => (
|
||||||
|
<path key={i} d={d} />
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
<span className="truncate tracking-wider">{dg.domain}</span>
|
||||||
|
<span className="ml-auto flex-shrink-0 rounded-full bg-gray-700 px-1.5 py-0.5 text-xs text-gray-300">
|
||||||
|
{dg.apis.length}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{/* 화살표 버튼 → 펼침/접힘 토글 */}
|
||||||
|
<button
|
||||||
|
onClick={() => toggleDomain(dg.domain)}
|
||||||
|
className="flex-shrink-0 rounded-md p-1.5 text-gray-500 hover:text-gray-300 transition-colors"
|
||||||
|
title={domainOpen ? '접기' : '펼치기'}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className={`h-3 w-3 transition-transform ${domainOpen ? 'rotate-90' : ''}`}
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* API items */}
|
||||||
|
{domainOpen && (
|
||||||
|
<div className="ml-4 mt-0.5 space-y-0.5">
|
||||||
|
{dg.apis.map((api) => {
|
||||||
|
const apiPath = `/api-hub/services/${api.serviceId}/apis/${api.apiId}`;
|
||||||
|
const isActive = location.pathname === apiPath;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NavLink
|
||||||
|
key={`${api.serviceId}-${api.apiId}`}
|
||||||
|
to={apiPath}
|
||||||
|
className={`block rounded-lg px-2.5 py-1.5 text-xs truncate transition-colors ${
|
||||||
|
isActive
|
||||||
|
? 'bg-gray-700 text-white'
|
||||||
|
: 'text-gray-300 hover:bg-gray-800 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{api.apiName}
|
||||||
|
</NavLink>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<div className="flex-1 ml-72">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="h-16 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between px-6">
|
||||||
|
<div />
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<button
|
||||||
|
onClick={toggleTheme}
|
||||||
|
className="rounded-lg p-2 text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||||
|
title={theme === 'light' ? 'Dark mode' : 'Light mode'}
|
||||||
|
>
|
||||||
|
{theme === 'light' ? (
|
||||||
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<span className="text-sm text-gray-500 dark:text-gray-400">Role:</span>
|
||||||
|
<div className="flex rounded-lg overflow-hidden border border-gray-300 dark:border-gray-600">
|
||||||
|
{ROLES.map((role) => (
|
||||||
|
<button
|
||||||
|
key={role}
|
||||||
|
onClick={() => setRole(role)}
|
||||||
|
className={`px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||||
|
user?.role === role
|
||||||
|
? 'bg-blue-600 text-white'
|
||||||
|
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{role}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<main className="p-6 bg-gray-100 dark:bg-gray-900 min-h-[calc(100vh-4rem)]">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ApiHubLayout;
|
||||||
@ -40,6 +40,9 @@ const navGroups: NavGroup[] = [
|
|||||||
adminOnly: true,
|
adminOnly: true,
|
||||||
items: [
|
items: [
|
||||||
{ label: 'Services', path: '/admin/services' },
|
{ label: 'Services', path: '/admin/services' },
|
||||||
|
{ label: 'Domains', path: '/admin/domains' },
|
||||||
|
{ label: 'API 관리', path: '/admin/apis' },
|
||||||
|
{ label: '공통 샘플 코드', path: '/admin/sample-code' },
|
||||||
{ label: 'Users', path: '/admin/users' },
|
{ label: 'Users', path: '/admin/users' },
|
||||||
{ label: 'Tenants', path: '/admin/tenants' },
|
{ label: 'Tenants', path: '/admin/tenants' },
|
||||||
],
|
],
|
||||||
@ -91,6 +94,21 @@ const MainLayout = () => {
|
|||||||
Dashboard
|
Dashboard
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|
||||||
|
{/* API Hub */}
|
||||||
|
<NavLink
|
||||||
|
to="/api-hub"
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`flex items-center gap-3 rounded-lg px-3 py-2 mt-4 text-sm font-medium transition-colors ${
|
||||||
|
isActive ? 'bg-gray-700 text-white' : 'text-gray-300 hover:bg-gray-800 hover:text-white'
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||||
|
</svg>
|
||||||
|
API Hub
|
||||||
|
</NavLink>
|
||||||
|
|
||||||
{/* Nav Groups */}
|
{/* Nav Groups */}
|
||||||
{navGroups.map((group) => {
|
{navGroups.map((group) => {
|
||||||
if (group.adminOnly && user?.role !== 'ADMIN') return null;
|
if (group.adminOnly && user?.role !== 'ADMIN') return null;
|
||||||
|
|||||||
822
frontend/src/pages/admin/ApiEditPage.tsx
Normal file
822
frontend/src/pages/admin/ApiEditPage.tsx
Normal file
@ -0,0 +1,822 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||||
|
import type {
|
||||||
|
ServiceInfo,
|
||||||
|
ApiDetailInfo,
|
||||||
|
UpdateServiceApiRequest,
|
||||||
|
SaveApiSpecRequest,
|
||||||
|
SaveApiParamRequest,
|
||||||
|
} from '../../types/service';
|
||||||
|
import {
|
||||||
|
getServices,
|
||||||
|
getApiDetail,
|
||||||
|
updateServiceApi,
|
||||||
|
saveApiSpec,
|
||||||
|
saveApiParams,
|
||||||
|
deleteServiceApi,
|
||||||
|
getDomains,
|
||||||
|
} from '../../services/serviceService';
|
||||||
|
import type { ApiDomainInfo } from '../../types/apihub';
|
||||||
|
|
||||||
|
const METHOD_COLOR: Record<string, string> = {
|
||||||
|
GET: 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300',
|
||||||
|
POST: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300',
|
||||||
|
PUT: 'bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300',
|
||||||
|
DELETE: 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300',
|
||||||
|
};
|
||||||
|
|
||||||
|
const INPUT_CLS =
|
||||||
|
'w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none text-sm';
|
||||||
|
|
||||||
|
const TABLE_INPUT_CLS =
|
||||||
|
'w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded px-2 py-1.5 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none';
|
||||||
|
|
||||||
|
const LABEL_CLS = 'block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1';
|
||||||
|
|
||||||
|
const ApiEditPage = () => {
|
||||||
|
const { serviceId: serviceIdStr, apiId: apiIdStr } = useParams<{
|
||||||
|
serviceId: string;
|
||||||
|
apiId: string;
|
||||||
|
}>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const serviceId = Number(serviceIdStr);
|
||||||
|
const apiId = Number(apiIdStr);
|
||||||
|
|
||||||
|
// Page state
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [notFound, setNotFound] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [saveMessage, setSaveMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
|
||||||
|
// Meta
|
||||||
|
const [serviceName, setServiceName] = useState('');
|
||||||
|
|
||||||
|
// Domains
|
||||||
|
const [domains, setDomains] = useState<ApiDomainInfo[]>([]);
|
||||||
|
|
||||||
|
// Basic info
|
||||||
|
const [apiMethod, setApiMethod] = useState('GET');
|
||||||
|
const [apiPath, setApiPath] = useState('');
|
||||||
|
const [apiName, setApiName] = useState('');
|
||||||
|
const [apiDomain, setApiDomain] = useState('');
|
||||||
|
const [apiSection, setApiSection] = useState('');
|
||||||
|
const [apiDescription, setApiDescription] = useState('');
|
||||||
|
const [apiIsActive, setApiIsActive] = useState(true);
|
||||||
|
|
||||||
|
// Spec
|
||||||
|
const [sampleUrl, setSampleUrl] = useState('');
|
||||||
|
const [authRequired, setAuthRequired] = useState(false);
|
||||||
|
const [authType, setAuthType] = useState('');
|
||||||
|
const [deprecated, setDeprecated] = useState(false);
|
||||||
|
const [dataFormat, setDataFormat] = useState('');
|
||||||
|
const [referenceUrl, setReferenceUrl] = useState('');
|
||||||
|
const [specNote, setSpecNote] = useState('');
|
||||||
|
|
||||||
|
// Params
|
||||||
|
const [requestParams, setRequestParams] = useState<SaveApiParamRequest[]>([]);
|
||||||
|
const [responseParams, setResponseParams] = useState<SaveApiParamRequest[]>([]);
|
||||||
|
|
||||||
|
const populateForm = useCallback((data: ApiDetailInfo, services: ServiceInfo[]) => {
|
||||||
|
const { api, spec, requestParams: rp, responseParams: resp } = data;
|
||||||
|
|
||||||
|
const svc = services.find((s) => s.serviceId === api.serviceId);
|
||||||
|
if (svc) setServiceName(svc.serviceName);
|
||||||
|
|
||||||
|
setApiMethod(api.apiMethod);
|
||||||
|
setApiPath(api.apiPath);
|
||||||
|
setApiName(api.apiName);
|
||||||
|
setApiDomain(api.apiDomain || '');
|
||||||
|
setApiSection(api.apiSection || '');
|
||||||
|
setApiDescription(api.description || '');
|
||||||
|
setApiIsActive(api.isActive);
|
||||||
|
|
||||||
|
if (spec) {
|
||||||
|
setSampleUrl(spec.sampleUrl || '');
|
||||||
|
setAuthRequired(spec.authRequired);
|
||||||
|
setAuthType(spec.authType || '');
|
||||||
|
setDeprecated(spec.deprecated);
|
||||||
|
setDataFormat(spec.dataFormat || '');
|
||||||
|
setReferenceUrl(spec.referenceUrl || '');
|
||||||
|
setSpecNote(spec.note || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
setRequestParams(
|
||||||
|
rp.map((p, i) => ({
|
||||||
|
paramType: 'REQUEST' as const,
|
||||||
|
paramName: p.paramName,
|
||||||
|
paramMeaning: p.paramMeaning || undefined,
|
||||||
|
paramDescription: p.paramDescription || undefined,
|
||||||
|
required: p.required,
|
||||||
|
defaultValue: p.defaultValue || undefined,
|
||||||
|
inputType: p.inputType || 'TEXT',
|
||||||
|
sortOrder: i,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
setResponseParams(
|
||||||
|
resp.map((p, i) => ({
|
||||||
|
paramType: 'RESPONSE' as const,
|
||||||
|
paramName: p.paramName,
|
||||||
|
paramMeaning: p.paramMeaning || undefined,
|
||||||
|
sortOrder: i,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const [svcRes, detailRes, domainRes] = await Promise.all([
|
||||||
|
getServices(),
|
||||||
|
getApiDetail(serviceId, apiId),
|
||||||
|
getDomains(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (domainRes.success && domainRes.data) {
|
||||||
|
setDomains(domainRes.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!detailRes.success || !detailRes.data) {
|
||||||
|
if (detailRes.message?.includes('404') || detailRes.message?.includes('찾을 수 없')) {
|
||||||
|
setNotFound(true);
|
||||||
|
} else {
|
||||||
|
setError(detailRes.message || 'API 정보를 불러오는데 실패했습니다.');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const services = svcRes.success && svcRes.data ? svcRes.data : [];
|
||||||
|
populateForm(detailRes.data, services);
|
||||||
|
} catch {
|
||||||
|
setError('API 정보를 불러오는데 실패했습니다.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
}, [serviceId, apiId, populateForm]);
|
||||||
|
|
||||||
|
// Param helpers
|
||||||
|
const addParam = () => {
|
||||||
|
const newParam: SaveApiParamRequest = {
|
||||||
|
paramType: 'REQUEST',
|
||||||
|
paramName: '',
|
||||||
|
inputType: 'TEXT',
|
||||||
|
sortOrder: requestParams.length,
|
||||||
|
};
|
||||||
|
setRequestParams((prev) => [...prev, newParam]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateParam = (index: number, field: string, value: string | boolean | undefined) => {
|
||||||
|
setRequestParams((prev) => prev.map((p, i) => (i === index ? { ...p, [field]: value } : p)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeParam = (index: number) => {
|
||||||
|
setRequestParams((prev) => prev.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
const addResponseParam = () => {
|
||||||
|
const newParam: SaveApiParamRequest = {
|
||||||
|
paramType: 'RESPONSE',
|
||||||
|
paramName: '',
|
||||||
|
sortOrder: responseParams.length,
|
||||||
|
};
|
||||||
|
setResponseParams((prev) => [...prev, newParam]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateResponseParam = (index: number, field: string, value: string | undefined) => {
|
||||||
|
setResponseParams((prev) => prev.map((p, i) => (i === index ? { ...p, [field]: value } : p)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeResponseParam = (index: number) => {
|
||||||
|
setResponseParams((prev) => prev.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
const [jsonInput, setJsonInput] = useState('');
|
||||||
|
const [jsonError, setJsonError] = useState<string | null>(null);
|
||||||
|
const [showJsonInput, setShowJsonInput] = useState(false);
|
||||||
|
|
||||||
|
const parseJsonToParams = () => {
|
||||||
|
setJsonError(null);
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(jsonInput);
|
||||||
|
const params: SaveApiParamRequest[] = [];
|
||||||
|
|
||||||
|
const extract = (obj: unknown, prefix: string) => {
|
||||||
|
if (obj === null || obj === undefined) return;
|
||||||
|
if (Array.isArray(obj)) {
|
||||||
|
params.push({ paramType: 'RESPONSE', paramName: prefix + '[]', sortOrder: params.length });
|
||||||
|
if (obj.length > 0 && typeof obj[0] === 'object' && obj[0] !== null) {
|
||||||
|
extract(obj[0], prefix + '[].');
|
||||||
|
}
|
||||||
|
} else if (typeof obj === 'object') {
|
||||||
|
for (const key of Object.keys(obj)) {
|
||||||
|
const fullKey = prefix ? prefix + key : key;
|
||||||
|
const val = (obj as Record<string, unknown>)[key];
|
||||||
|
if (val !== null && typeof val === 'object') {
|
||||||
|
extract(val, fullKey + (Array.isArray(val) ? '' : '.'));
|
||||||
|
} else {
|
||||||
|
params.push({ paramType: 'RESPONSE', paramName: fullKey, sortOrder: params.length });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
extract(parsed, '');
|
||||||
|
if (params.length === 0) {
|
||||||
|
setJsonError('파싱할 키가 없습니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setResponseParams(params);
|
||||||
|
setJsonInput('');
|
||||||
|
setShowJsonInput(false);
|
||||||
|
} catch {
|
||||||
|
setJsonError('올바른 JSON 형식이 아닙니다.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
setSaveMessage(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const basicReq: UpdateServiceApiRequest = {
|
||||||
|
apiMethod,
|
||||||
|
apiPath,
|
||||||
|
apiName,
|
||||||
|
apiDomain: apiDomain || undefined,
|
||||||
|
apiSection: apiSection || undefined,
|
||||||
|
description: apiDescription || undefined,
|
||||||
|
isActive: apiIsActive,
|
||||||
|
};
|
||||||
|
|
||||||
|
const specReq: SaveApiSpecRequest = {
|
||||||
|
sampleUrl: sampleUrl || undefined,
|
||||||
|
authRequired,
|
||||||
|
authType: authType || undefined,
|
||||||
|
deprecated,
|
||||||
|
dataFormat: dataFormat || undefined,
|
||||||
|
referenceUrl: referenceUrl || undefined,
|
||||||
|
note: specNote || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const allParams: SaveApiParamRequest[] = [
|
||||||
|
...requestParams.map((p, i) => ({ ...p, sortOrder: i })),
|
||||||
|
...responseParams.map((p, i) => ({ ...p, sortOrder: i })),
|
||||||
|
];
|
||||||
|
|
||||||
|
const [basicRes, specRes, paramsRes] = await Promise.all([
|
||||||
|
updateServiceApi(serviceId, apiId, basicReq),
|
||||||
|
saveApiSpec(serviceId, apiId, specReq),
|
||||||
|
saveApiParams(serviceId, apiId, allParams),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const basicOk = basicRes.success;
|
||||||
|
const specOk = specRes.success;
|
||||||
|
const paramsOk = paramsRes.success;
|
||||||
|
|
||||||
|
if (basicOk && specOk && paramsOk) {
|
||||||
|
setSaveMessage({ type: 'success', text: '저장되었습니다.' });
|
||||||
|
} else {
|
||||||
|
const errMsg =
|
||||||
|
(!basicOk ? basicRes.message : null) ||
|
||||||
|
(!specOk ? specRes.message : null) ||
|
||||||
|
(!paramsOk ? paramsRes.message : null) ||
|
||||||
|
'일부 항목 저장에 실패했습니다.';
|
||||||
|
setSaveMessage({ type: 'error', text: errMsg });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setSaveMessage({ type: 'error', text: '저장 중 오류가 발생했습니다.' });
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
setTimeout(() => setSaveMessage(null), 3000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!window.confirm('이 API를 삭제하시겠습니까?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await deleteServiceApi(serviceId, apiId);
|
||||||
|
if (res.success) {
|
||||||
|
navigate('/admin/apis');
|
||||||
|
} else {
|
||||||
|
setSaveMessage({ type: 'error', text: res.message || 'API 삭제에 실패했습니다.' });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setSaveMessage({ type: 'error', text: 'API 삭제 중 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Loading
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<div className="w-8 h-8 border-4 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not found
|
||||||
|
if (notFound) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-5xl mx-auto text-center py-20">
|
||||||
|
<p className="text-gray-500 dark:text-gray-400 text-lg">API를 찾을 수 없습니다.</p>
|
||||||
|
<Link
|
||||||
|
to="/admin/apis"
|
||||||
|
className="mt-4 inline-block text-blue-600 dark:text-blue-400 hover:underline text-sm"
|
||||||
|
>
|
||||||
|
API 목록으로 돌아가기
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-5xl mx-auto py-10">
|
||||||
|
<div className="p-4 bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-400 rounded-lg text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
to="/admin/apis"
|
||||||
|
className="mt-4 inline-block text-blue-600 dark:text-blue-400 hover:underline text-sm"
|
||||||
|
>
|
||||||
|
API 목록으로 돌아가기
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-5xl mx-auto">
|
||||||
|
{/* Breadcrumb */}
|
||||||
|
<nav className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 mb-4">
|
||||||
|
<Link
|
||||||
|
to="/admin/apis"
|
||||||
|
className="hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
||||||
|
>
|
||||||
|
API 관리
|
||||||
|
</Link>
|
||||||
|
<span>/</span>
|
||||||
|
<span className="text-gray-700 dark:text-gray-300">{serviceName || `서비스 #${serviceId}`}</span>
|
||||||
|
<span>/</span>
|
||||||
|
<span className="text-gray-900 dark:text-gray-100 font-medium">{apiName || `API #${apiId}`}</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between mb-2">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">{apiName}</h1>
|
||||||
|
<div className="flex items-center gap-2 mt-1.5">
|
||||||
|
<span
|
||||||
|
className={`inline-block px-2 py-0.5 rounded text-xs font-bold ${
|
||||||
|
METHOD_COLOR[apiMethod] ?? 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{apiMethod}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-sm text-gray-600 dark:text-gray-400">{apiPath}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
className="px-4 py-2 rounded-lg bg-red-600 hover:bg-red-700 text-white text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
className="px-4 py-2 rounded-lg bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
{saving ? '저장 중...' : '저장'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Save message */}
|
||||||
|
{saveMessage && (
|
||||||
|
<div
|
||||||
|
className={`mb-4 p-3 rounded-lg text-sm ${
|
||||||
|
saveMessage.type === 'success'
|
||||||
|
? 'bg-green-50 dark:bg-green-900/30 text-green-700 dark:text-green-400'
|
||||||
|
: 'bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{saveMessage.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sections */}
|
||||||
|
<div className="space-y-6 mt-6">
|
||||||
|
{/* Section 1: 기본 정보 */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||||
|
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100 mb-4">기본 정보</h2>
|
||||||
|
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||||
|
<div>
|
||||||
|
<label className={LABEL_CLS}>Method</label>
|
||||||
|
<select
|
||||||
|
value={apiMethod}
|
||||||
|
onChange={(e) => setApiMethod(e.target.value)}
|
||||||
|
className={INPUT_CLS}
|
||||||
|
>
|
||||||
|
{['GET', 'POST', 'PUT', 'DELETE'].map((m) => (
|
||||||
|
<option key={m} value={m}>{m}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={LABEL_CLS}>API Path</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={apiPath}
|
||||||
|
onChange={(e) => setApiPath(e.target.value)}
|
||||||
|
placeholder="/api/v1/example"
|
||||||
|
className={`${INPUT_CLS} font-mono`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={LABEL_CLS}>API명</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={apiName}
|
||||||
|
onChange={(e) => setApiName(e.target.value)}
|
||||||
|
placeholder="API 이름"
|
||||||
|
className={INPUT_CLS}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={LABEL_CLS}>Domain</label>
|
||||||
|
<select
|
||||||
|
value={apiDomain}
|
||||||
|
onChange={(e) => setApiDomain(e.target.value)}
|
||||||
|
className={INPUT_CLS}
|
||||||
|
>
|
||||||
|
<option value="">선택 안함</option>
|
||||||
|
{domains.map((d) => (
|
||||||
|
<option key={d.domainId} value={d.domainName}>{d.domainName}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={LABEL_CLS}>Section</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={apiSection}
|
||||||
|
onChange={(e) => setApiSection(e.target.value)}
|
||||||
|
placeholder="섹션 (선택)"
|
||||||
|
className={INPUT_CLS}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 pt-6">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="apiIsActive"
|
||||||
|
checked={apiIsActive}
|
||||||
|
onChange={(e) => setApiIsActive(e.target.checked)}
|
||||||
|
className="w-4 h-4 text-blue-600 rounded border-gray-300 dark:border-gray-600 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
<label htmlFor="apiIsActive" className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Active
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={LABEL_CLS}>설명</label>
|
||||||
|
<textarea
|
||||||
|
value={apiDescription}
|
||||||
|
onChange={(e) => setApiDescription(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
placeholder="API 설명 (선택)"
|
||||||
|
className={`${INPUT_CLS} resize-none`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Section 2: API 명세 */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||||
|
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100 mb-4">API 명세</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className={LABEL_CLS}>샘플 URL</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={sampleUrl}
|
||||||
|
onChange={(e) => setSampleUrl(e.target.value)}
|
||||||
|
placeholder="https://example.com/api/v1/..."
|
||||||
|
className={INPUT_CLS}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="authRequired"
|
||||||
|
checked={authRequired}
|
||||||
|
onChange={(e) => setAuthRequired(e.target.checked)}
|
||||||
|
className="w-4 h-4 text-blue-600 rounded border-gray-300 dark:border-gray-600 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
<label htmlFor="authRequired" className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
인증 필요
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={LABEL_CLS}>인증 방식</label>
|
||||||
|
<select
|
||||||
|
value={authType}
|
||||||
|
onChange={(e) => setAuthType(e.target.value)}
|
||||||
|
className={INPUT_CLS}
|
||||||
|
>
|
||||||
|
<option value="">없음</option>
|
||||||
|
<option value="API_KEY">API_KEY</option>
|
||||||
|
<option value="JWT">JWT</option>
|
||||||
|
<option value="NONE">NONE</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="deprecated"
|
||||||
|
checked={deprecated}
|
||||||
|
onChange={(e) => setDeprecated(e.target.checked)}
|
||||||
|
className="w-4 h-4 text-blue-600 rounded border-gray-300 dark:border-gray-600 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
<label htmlFor="deprecated" className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Deprecated
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={LABEL_CLS}>데이터 포맷</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={dataFormat}
|
||||||
|
onChange={(e) => setDataFormat(e.target.value)}
|
||||||
|
placeholder="JSON, XML 등"
|
||||||
|
className={INPUT_CLS}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={LABEL_CLS}>참고자료 URL</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={referenceUrl}
|
||||||
|
onChange={(e) => setReferenceUrl(e.target.value)}
|
||||||
|
placeholder="https://..."
|
||||||
|
className={INPUT_CLS}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={LABEL_CLS}>비고</label>
|
||||||
|
<textarea
|
||||||
|
value={specNote}
|
||||||
|
onChange={(e) => setSpecNote(e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
placeholder="추가 참고사항 (선택)"
|
||||||
|
className={`${INPUT_CLS} resize-none`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Section 3: 요청인자 */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100">요청인자</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => addParam()}
|
||||||
|
className="px-3 py-1.5 rounded-lg bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
행 추가
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<th className="px-2 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 w-10">#</th>
|
||||||
|
<th className="px-2 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400">인자명</th>
|
||||||
|
<th className="px-2 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400">의미</th>
|
||||||
|
<th className="px-2 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400">설명</th>
|
||||||
|
<th className="px-2 py-2 text-center text-xs font-medium text-gray-500 dark:text-gray-400 w-14">필수</th>
|
||||||
|
<th className="px-2 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 w-28">입력유형</th>
|
||||||
|
<th className="px-2 py-2 w-10" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||||
|
{requestParams.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={7}
|
||||||
|
className="px-2 py-6 text-center text-gray-400 dark:text-gray-500 text-sm"
|
||||||
|
>
|
||||||
|
등록된 요청인자가 없습니다
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
requestParams.map((param, idx) => (
|
||||||
|
<tr key={idx}>
|
||||||
|
<td className="px-2 py-1.5 text-gray-500 dark:text-gray-400 text-center text-xs">
|
||||||
|
{idx + 1}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1.5">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={param.paramName}
|
||||||
|
onChange={(e) => updateParam(idx, 'paramName', e.target.value)}
|
||||||
|
placeholder="paramName"
|
||||||
|
className={TABLE_INPUT_CLS}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1.5">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={param.paramMeaning ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateParam(idx, 'paramMeaning', e.target.value || undefined)
|
||||||
|
}
|
||||||
|
placeholder="의미"
|
||||||
|
className={TABLE_INPUT_CLS}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1.5">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={param.paramDescription ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateParam(idx, 'paramDescription', e.target.value || undefined)
|
||||||
|
}
|
||||||
|
placeholder="설명"
|
||||||
|
className={TABLE_INPUT_CLS}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1.5 text-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={param.required ?? false}
|
||||||
|
onChange={(e) => updateParam(idx, 'required', e.target.checked)}
|
||||||
|
className="w-4 h-4 text-blue-600 rounded border-gray-300 dark:border-gray-600 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1.5">
|
||||||
|
<select
|
||||||
|
value={param.inputType ?? 'TEXT'}
|
||||||
|
onChange={(e) => updateParam(idx, 'inputType', e.target.value)}
|
||||||
|
className={TABLE_INPUT_CLS}
|
||||||
|
>
|
||||||
|
<option value="TEXT">TEXT</option>
|
||||||
|
<option value="NUMBER">NUMBER</option>
|
||||||
|
<option value="DATE">DATE</option>
|
||||||
|
<option value="DATETIME">DATETIME</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1.5 text-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeParam(idx)}
|
||||||
|
className="text-red-500 hover:text-red-700 dark:hover:text-red-400 font-bold text-base leading-none"
|
||||||
|
title="삭제"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Section 4: 출력결과 */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100">출력결과</h2>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowJsonInput((v) => !v)}
|
||||||
|
className="px-3 py-1.5 rounded-lg bg-indigo-100 hover:bg-indigo-200 dark:bg-indigo-900 dark:hover:bg-indigo-800 text-indigo-700 dark:text-indigo-300 text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
{showJsonInput ? 'JSON 닫기' : 'JSON 파싱'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={addResponseParam}
|
||||||
|
className="px-3 py-1.5 rounded-lg bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
행 추가
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showJsonInput && (
|
||||||
|
<div className="mb-4 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg border border-gray-200 dark:border-gray-600">
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mb-2">
|
||||||
|
JSON 응답 예시를 붙여넣으면 키를 자동으로 추출합니다. (기존 목록을 대체합니다)
|
||||||
|
</p>
|
||||||
|
<textarea
|
||||||
|
value={jsonInput}
|
||||||
|
onChange={(e) => { setJsonInput(e.target.value); setJsonError(null); }}
|
||||||
|
rows={6}
|
||||||
|
placeholder={'{\n "result": "success",\n "data": {\n "id": 1,\n "name": "example"\n }\n}'}
|
||||||
|
className={`${INPUT_CLS} font-mono resize-none`}
|
||||||
|
/>
|
||||||
|
{jsonError && (
|
||||||
|
<p className="mt-1.5 text-xs text-red-500">{jsonError}</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={parseJsonToParams}
|
||||||
|
disabled={!jsonInput.trim()}
|
||||||
|
className="mt-2 px-4 py-1.5 rounded-lg bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 text-white text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
파싱
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<th className="px-2 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 w-10">#</th>
|
||||||
|
<th className="px-2 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400">변수명</th>
|
||||||
|
<th className="px-2 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400">의미(단위)</th>
|
||||||
|
<th className="px-2 py-2 w-10" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||||
|
{responseParams.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={4}
|
||||||
|
className="px-2 py-6 text-center text-gray-400 dark:text-gray-500 text-sm"
|
||||||
|
>
|
||||||
|
등록된 출력결과가 없습니다
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
responseParams.map((param, idx) => (
|
||||||
|
<tr key={idx}>
|
||||||
|
<td className="px-2 py-1.5 text-gray-500 dark:text-gray-400 text-center text-xs">
|
||||||
|
{idx + 1}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1.5">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={param.paramName}
|
||||||
|
onChange={(e) => updateResponseParam(idx, 'paramName', e.target.value)}
|
||||||
|
placeholder="variableName"
|
||||||
|
className={TABLE_INPUT_CLS}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1.5">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={param.paramMeaning ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateResponseParam(idx, 'paramMeaning', e.target.value || undefined)
|
||||||
|
}
|
||||||
|
placeholder="의미(단위)"
|
||||||
|
className={TABLE_INPUT_CLS}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1.5 text-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeResponseParam(idx)}
|
||||||
|
className="text-red-500 hover:text-red-700 dark:hover:text-red-400 font-bold text-base leading-none"
|
||||||
|
title="삭제"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ApiEditPage;
|
||||||
430
frontend/src/pages/admin/ApisPage.tsx
Normal file
430
frontend/src/pages/admin/ApisPage.tsx
Normal file
@ -0,0 +1,430 @@
|
|||||||
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import type { ServiceInfo, ServiceApi, CreateServiceApiRequest } from '../../types/service';
|
||||||
|
import { getServices, getServiceApis, createServiceApi } from '../../services/serviceService';
|
||||||
|
|
||||||
|
const METHOD_COLOR: Record<string, string> = {
|
||||||
|
GET: 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300',
|
||||||
|
POST: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300',
|
||||||
|
PUT: 'bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300',
|
||||||
|
DELETE: 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface FlatApi extends ServiceApi {
|
||||||
|
serviceName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ApisPage = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [services, setServices] = useState<ServiceInfo[]>([]);
|
||||||
|
const [allApis, setAllApis] = useState<FlatApi[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Filter state
|
||||||
|
const [filterServiceId, setFilterServiceId] = useState<number | 'all'>('all');
|
||||||
|
const [searchText, setSearchText] = useState('');
|
||||||
|
const [filterActive, setFilterActive] = useState<'all' | 'active' | 'inactive'>('all');
|
||||||
|
|
||||||
|
// Create API modal state
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [modalError, setModalError] = useState<string | null>(null);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [modalServiceId, setModalServiceId] = useState<number | ''>('');
|
||||||
|
const [modalMethod, setModalMethod] = useState('GET');
|
||||||
|
const [modalPath, setModalPath] = useState('');
|
||||||
|
const [modalName, setModalName] = useState('');
|
||||||
|
const [modalDomain, setModalDomain] = useState('');
|
||||||
|
const [modalSection, setModalSection] = useState('');
|
||||||
|
const [modalDescription, setModalDescription] = useState('');
|
||||||
|
|
||||||
|
const fetchAll = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const svcRes = await getServices();
|
||||||
|
if (!svcRes.success || !svcRes.data) {
|
||||||
|
setError(svcRes.message || '서비스 목록을 불러오는데 실패했습니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const loadedServices = svcRes.data;
|
||||||
|
setServices(loadedServices);
|
||||||
|
|
||||||
|
if (loadedServices.length === 0) {
|
||||||
|
setAllApis([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
loadedServices.map((svc) => getServiceApis(svc.serviceId)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const flat: FlatApi[] = [];
|
||||||
|
results.forEach((result, idx) => {
|
||||||
|
if (result.status === 'fulfilled' && result.value.success && result.value.data) {
|
||||||
|
result.value.data.forEach((api) => {
|
||||||
|
flat.push({ ...api, serviceName: loadedServices[idx].serviceName });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setAllApis(flat);
|
||||||
|
} catch {
|
||||||
|
setError('API 목록을 불러오는데 실패했습니다.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAll();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const filteredApis = useMemo(() => {
|
||||||
|
return allApis.filter((api) => {
|
||||||
|
if (filterServiceId !== 'all' && api.serviceId !== filterServiceId) return false;
|
||||||
|
if (filterActive === 'active' && !api.isActive) return false;
|
||||||
|
if (filterActive === 'inactive' && api.isActive) return false;
|
||||||
|
if (searchText.trim()) {
|
||||||
|
const q = searchText.trim().toLowerCase();
|
||||||
|
if (!api.apiName.toLowerCase().includes(q) && !api.apiPath.toLowerCase().includes(q)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [allApis, filterServiceId, filterActive, searchText]);
|
||||||
|
|
||||||
|
const handleOpenModal = () => {
|
||||||
|
setModalServiceId(services.length > 0 ? services[0].serviceId : '');
|
||||||
|
setModalMethod('GET');
|
||||||
|
setModalPath('');
|
||||||
|
setModalName('');
|
||||||
|
setModalDomain('');
|
||||||
|
setModalSection('');
|
||||||
|
setModalDescription('');
|
||||||
|
setModalError(null);
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseModal = () => {
|
||||||
|
setIsModalOpen(false);
|
||||||
|
setModalError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleModalSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (modalServiceId === '') return;
|
||||||
|
setModalError(null);
|
||||||
|
setSubmitting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const req: CreateServiceApiRequest = {
|
||||||
|
apiMethod: modalMethod,
|
||||||
|
apiPath: modalPath,
|
||||||
|
apiName: modalName,
|
||||||
|
apiDomain: modalDomain || undefined,
|
||||||
|
apiSection: modalSection || undefined,
|
||||||
|
description: modalDescription || undefined,
|
||||||
|
};
|
||||||
|
const res = await createServiceApi(modalServiceId as number, req);
|
||||||
|
if (!res.success) {
|
||||||
|
setModalError(res.message || 'API 생성에 실패했습니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handleCloseModal();
|
||||||
|
await fetchAll();
|
||||||
|
if (res.data) {
|
||||||
|
navigate(`/admin/apis/${res.data.serviceId}/${res.data.apiId}`);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setModalError('API 생성에 실패했습니다.');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-7xl mx-auto text-center py-10 text-gray-500 dark:text-gray-400">
|
||||||
|
로딩 중...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">API 관리</h1>
|
||||||
|
<button
|
||||||
|
onClick={handleOpenModal}
|
||||||
|
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium"
|
||||||
|
>
|
||||||
|
API 생성
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Global error */}
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 p-3 bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-400 rounded-lg text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex flex-wrap gap-3 mb-4">
|
||||||
|
<select
|
||||||
|
value={filterServiceId}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFilterServiceId(e.target.value === 'all' ? 'all' : Number(e.target.value))
|
||||||
|
}
|
||||||
|
className="px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<option value="all">전체 서비스</option>
|
||||||
|
{services.map((svc) => (
|
||||||
|
<option key={svc.serviceId} value={svc.serviceId}>
|
||||||
|
{svc.serviceName}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchText}
|
||||||
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
|
placeholder="API명, Path 검색"
|
||||||
|
className="px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 min-w-[200px]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex rounded-lg border border-gray-300 dark:border-gray-600 overflow-hidden text-sm">
|
||||||
|
{(['all', 'active', 'inactive'] as const).map((v) => (
|
||||||
|
<button
|
||||||
|
key={v}
|
||||||
|
onClick={() => setFilterActive(v)}
|
||||||
|
className={`px-3 py-2 font-medium ${
|
||||||
|
filterActive === v
|
||||||
|
? 'bg-blue-600 text-white'
|
||||||
|
: 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{v === 'all' ? '전체' : v === 'active' ? 'Active' : 'Inactive'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div className="overflow-x-auto bg-white dark:bg-gray-800 rounded-lg shadow">
|
||||||
|
<table className="w-full divide-y divide-gray-200 dark:divide-gray-700 text-sm">
|
||||||
|
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">서비스</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Method</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Path</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">API명</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Domain</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Section</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Active</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">명세</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{filteredApis.map((api) => (
|
||||||
|
<tr
|
||||||
|
key={`${api.serviceId}-${api.apiId}`}
|
||||||
|
onClick={() => navigate(`/admin/apis/${api.serviceId}/${api.apiId}`)}
|
||||||
|
className="cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3 text-gray-700 dark:text-gray-300">{api.serviceName}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span
|
||||||
|
className={`inline-block px-2 py-0.5 rounded text-xs font-bold ${
|
||||||
|
METHOD_COLOR[api.apiMethod] ?? 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{api.apiMethod}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-gray-800 dark:text-gray-200 truncate max-w-[240px]">
|
||||||
|
{api.apiPath}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-gray-900 dark:text-gray-100">{api.apiName}</td>
|
||||||
|
<td className="px-4 py-3 text-gray-500 dark:text-gray-400">{api.apiDomain || '-'}</td>
|
||||||
|
<td className="px-4 py-3 text-gray-500 dark:text-gray-400">{api.apiSection || '-'}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span
|
||||||
|
className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||||
|
api.isActive
|
||||||
|
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300'
|
||||||
|
: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{api.isActive ? 'Active' : 'Inactive'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className="text-gray-400 dark:text-gray-500">-</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{filteredApis.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={8} className="px-4 py-8 text-center text-gray-400 dark:text-gray-500">
|
||||||
|
등록된 API가 없습니다
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create API Modal */}
|
||||||
|
{isModalOpen && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl w-full max-w-lg mx-4">
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">API 생성</h2>
|
||||||
|
<button
|
||||||
|
onClick={handleCloseModal}
|
||||||
|
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 text-xl font-bold leading-none"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleModalSubmit} className="px-6 py-4 space-y-4">
|
||||||
|
{modalError && (
|
||||||
|
<div className="p-3 bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-400 rounded-lg text-sm">
|
||||||
|
{modalError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
서비스 <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={modalServiceId}
|
||||||
|
onChange={(e) => setModalServiceId(Number(e.target.value))}
|
||||||
|
required
|
||||||
|
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
{services.map((svc) => (
|
||||||
|
<option key={svc.serviceId} value={svc.serviceId}>
|
||||||
|
{svc.serviceName}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Method <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={modalMethod}
|
||||||
|
onChange={(e) => setModalMethod(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
{['GET', 'POST', 'PUT', 'DELETE'].map((m) => (
|
||||||
|
<option key={m} value={m}>{m}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
API Path <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={modalPath}
|
||||||
|
onChange={(e) => setModalPath(e.target.value)}
|
||||||
|
required
|
||||||
|
placeholder="/api/v1/example"
|
||||||
|
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
API명 <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={modalName}
|
||||||
|
onChange={(e) => setModalName(e.target.value)}
|
||||||
|
required
|
||||||
|
placeholder="API 이름"
|
||||||
|
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Domain
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={modalDomain}
|
||||||
|
onChange={(e) => setModalDomain(e.target.value)}
|
||||||
|
placeholder="도메인 (선택)"
|
||||||
|
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Section
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={modalSection}
|
||||||
|
onChange={(e) => setModalSection(e.target.value)}
|
||||||
|
placeholder="섹션 (선택)"
|
||||||
|
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
설명
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={modalDescription}
|
||||||
|
onChange={(e) => setModalDescription(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
placeholder="API 설명 (선택)"
|
||||||
|
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCloseModal}
|
||||||
|
className="px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 text-sm font-medium hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting}
|
||||||
|
className="px-4 py-2 rounded-lg bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white text-sm font-medium"
|
||||||
|
>
|
||||||
|
{submitting ? '생성 중...' : '생성'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ApisPage;
|
||||||
302
frontend/src/pages/admin/DomainsPage.tsx
Normal file
302
frontend/src/pages/admin/DomainsPage.tsx
Normal file
@ -0,0 +1,302 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import type { ApiDomainInfo } from '../../types/apihub';
|
||||||
|
import type { CreateDomainRequest, UpdateDomainRequest } from '../../services/serviceService';
|
||||||
|
import { getDomains, createDomain, updateDomain, deleteDomain } from '../../services/serviceService';
|
||||||
|
|
||||||
|
const DEFAULT_ICON_PATHS = ['M3.75 9.776c.112-.017.227-.026.344-.026h15.812c.117 0 .232.009.344.026m-16.5 0a2.25 2.25 0 00-1.883 2.542l.857 6a2.25 2.25 0 002.227 1.932H19.05a2.25 2.25 0 002.227-1.932l.857-6a2.25 2.25 0 00-1.883-2.542m-16.5 0V6A2.25 2.25 0 016 3.75h3.879a1.5 1.5 0 011.06.44l2.122 2.12a1.5 1.5 0 001.06.44H18A2.25 2.25 0 0120.25 9v.776'];
|
||||||
|
|
||||||
|
const parseIconPaths = (iconPath: string | null): string[] => {
|
||||||
|
if (!iconPath) return DEFAULT_ICON_PATHS;
|
||||||
|
const pathRegex = /d="([^"]+)"/g;
|
||||||
|
const matches: string[] = [];
|
||||||
|
let m;
|
||||||
|
while ((m = pathRegex.exec(iconPath)) !== null) {
|
||||||
|
matches.push(m[1]);
|
||||||
|
}
|
||||||
|
return matches.length > 0 ? matches : [iconPath];
|
||||||
|
};
|
||||||
|
|
||||||
|
const DomainsPage = () => {
|
||||||
|
const [domains, setDomains] = useState<ApiDomainInfo[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [editingDomain, setEditingDomain] = useState<ApiDomainInfo | null>(null);
|
||||||
|
const [domainName, setDomainName] = useState('');
|
||||||
|
const [iconPath, setIconPath] = useState('');
|
||||||
|
const [sortOrder, setSortOrder] = useState(0);
|
||||||
|
|
||||||
|
const fetchDomains = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const res = await getDomains();
|
||||||
|
if (res.success && res.data) {
|
||||||
|
setDomains(res.data);
|
||||||
|
} else {
|
||||||
|
setError(res.message || '도메인 목록을 불러오는데 실패했습니다.');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError('도메인 목록을 불러오는데 실패했습니다.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchDomains();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleOpenCreate = () => {
|
||||||
|
setEditingDomain(null);
|
||||||
|
setDomainName('');
|
||||||
|
setIconPath('');
|
||||||
|
setSortOrder(domains.length > 0 ? Math.max(...domains.map((d) => d.sortOrder)) + 1 : 0);
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenEdit = (domain: ApiDomainInfo) => {
|
||||||
|
setEditingDomain(domain);
|
||||||
|
setDomainName(domain.domainName);
|
||||||
|
setIconPath(domain.iconPath ?? '');
|
||||||
|
setSortOrder(domain.sortOrder);
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseModal = () => {
|
||||||
|
setIsModalOpen(false);
|
||||||
|
setEditingDomain(null);
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (editingDomain) {
|
||||||
|
const req: UpdateDomainRequest = {
|
||||||
|
domainName,
|
||||||
|
iconPath: iconPath || null,
|
||||||
|
sortOrder,
|
||||||
|
};
|
||||||
|
const res = await updateDomain(editingDomain.domainId, req);
|
||||||
|
if (!res.success) {
|
||||||
|
setError(res.message || '도메인 수정에 실패했습니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const req: CreateDomainRequest = {
|
||||||
|
domainName,
|
||||||
|
iconPath: iconPath || null,
|
||||||
|
sortOrder,
|
||||||
|
};
|
||||||
|
const res = await createDomain(req);
|
||||||
|
if (!res.success) {
|
||||||
|
setError(res.message || '도메인 생성에 실패했습니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
handleCloseModal();
|
||||||
|
await fetchDomains();
|
||||||
|
} catch {
|
||||||
|
setError(editingDomain ? '도메인 수정에 실패했습니다.' : '도메인 생성에 실패했습니다.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (domain: ApiDomainInfo) => {
|
||||||
|
if (!window.confirm(`'${domain.domainName}' 도메인을 삭제하시겠습니까?`)) return;
|
||||||
|
try {
|
||||||
|
const res = await deleteDomain(domain.domainId);
|
||||||
|
if (!res.success) {
|
||||||
|
setError(res.message || '도메인 삭제에 실패했습니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await fetchDomains();
|
||||||
|
} catch {
|
||||||
|
setError('도메인 삭제에 실패했습니다.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const previewPath = iconPath.trim() || null;
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="max-w-7xl mx-auto text-center py-10 text-gray-500 dark:text-gray-400">로딩 중...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Domains</h1>
|
||||||
|
<button
|
||||||
|
onClick={handleOpenCreate}
|
||||||
|
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium"
|
||||||
|
>
|
||||||
|
도메인 추가
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && !isModalOpen && (
|
||||||
|
<div className="mb-4 p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="overflow-x-auto bg-white dark:bg-gray-800 rounded-lg shadow">
|
||||||
|
<table className="w-full divide-y divide-gray-200 dark:divide-gray-700 text-sm">
|
||||||
|
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">#</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">아이콘</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">도메인명</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">정렬순서</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{domains.map((domain, index) => (
|
||||||
|
<tr key={domain.domainId} className="hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||||
|
<td className="px-4 py-3 text-gray-500 dark:text-gray-400">{index + 1}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<svg
|
||||||
|
className="h-5 w-5 text-gray-500 dark:text-gray-400"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
{parseIconPaths(domain.iconPath).map((d, i) => (
|
||||||
|
<path key={i} d={d} />
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-gray-900 dark:text-gray-100 font-medium">{domain.domainName}</td>
|
||||||
|
<td className="px-4 py-3 text-gray-500 dark:text-gray-400">{domain.sortOrder}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleOpenEdit(domain)}
|
||||||
|
className="bg-blue-100 hover:bg-blue-200 text-blue-700 dark:bg-blue-900/30 dark:hover:bg-blue-800/40 dark:text-blue-400 px-3 py-1 rounded-lg text-sm font-medium"
|
||||||
|
>
|
||||||
|
수정
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(domain)}
|
||||||
|
className="bg-red-100 hover:bg-red-200 text-red-700 dark:bg-red-900/30 dark:hover:bg-red-800/40 dark:text-red-400 px-3 py-1 rounded-lg text-sm font-medium"
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{domains.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="px-4 py-8 text-center text-gray-400 dark:text-gray-500">
|
||||||
|
등록된 도메인이 없습니다.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isModalOpen && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md mx-4">
|
||||||
|
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{editingDomain ? '도메인 수정' : '도메인 추가'}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="px-6 py-4 space-y-4 max-h-[70vh] overflow-y-auto">
|
||||||
|
{error && (
|
||||||
|
<div className="p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
도메인명
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={domainName}
|
||||||
|
onChange={(e) => setDomainName(e.target.value)}
|
||||||
|
required
|
||||||
|
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="flex items-center justify-between text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
<span>SVG Path</span>
|
||||||
|
<span className="text-xs font-normal text-gray-400 dark:text-gray-500">
|
||||||
|
참고:
|
||||||
|
<a href="https://heroicons.com" target="_blank" rel="noopener noreferrer" className="ml-1 text-blue-500 hover:text-blue-600 underline">Heroicons</a>
|
||||||
|
<a href="https://lucide.dev" target="_blank" rel="noopener noreferrer" className="ml-1 text-blue-500 hover:text-blue-600 underline">Lucide</a>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={iconPath}
|
||||||
|
onChange={(e) => setIconPath(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
placeholder="M4 6a2 2 0 012-2h8..."
|
||||||
|
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none font-mono text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
아이콘 미리보기
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center justify-center w-16 h-16 bg-gray-100 dark:bg-gray-700 rounded-lg">
|
||||||
|
<svg
|
||||||
|
className="h-8 w-8 text-gray-600 dark:text-gray-300"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
{parseIconPaths(previewPath).map((d, i) => (
|
||||||
|
<path key={i} d={d} />
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
정렬순서
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={sortOrder}
|
||||||
|
onChange={(e) => setSortOrder(Number(e.target.value))}
|
||||||
|
min={0}
|
||||||
|
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCloseModal}
|
||||||
|
className="bg-gray-200 hover:bg-gray-300 dark:bg-gray-600 dark:hover:bg-gray-500 text-gray-700 dark:text-gray-200 px-4 py-2 rounded-lg text-sm font-medium"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DomainsPage;
|
||||||
107
frontend/src/pages/admin/SampleCodePage.tsx
Normal file
107
frontend/src/pages/admin/SampleCodePage.tsx
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { getSystemConfig, updateSystemConfig } from '../../services/configService';
|
||||||
|
|
||||||
|
const COMMON_SAMPLE_CODE_KEY = 'COMMON_SAMPLE_CODE';
|
||||||
|
|
||||||
|
const INPUT_CLS =
|
||||||
|
'w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none text-sm font-mono';
|
||||||
|
|
||||||
|
const SampleCodePage = () => {
|
||||||
|
const [sampleCode, setSampleCode] = useState('');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchConfig = async () => {
|
||||||
|
try {
|
||||||
|
const res = await getSystemConfig(COMMON_SAMPLE_CODE_KEY);
|
||||||
|
if (res.success && res.data?.configValue != null) {
|
||||||
|
setSampleCode(res.data.configValue);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setMessage({ type: 'error', text: '샘플 코드를 불러오는데 실패했습니다.' });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchConfig();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
setMessage(null);
|
||||||
|
try {
|
||||||
|
const res = await updateSystemConfig(COMMON_SAMPLE_CODE_KEY, sampleCode);
|
||||||
|
if (res.success) {
|
||||||
|
setMessage({ type: 'success', text: '저장되었습니다.' });
|
||||||
|
} else {
|
||||||
|
setMessage({ type: 'error', text: res.message || '저장에 실패했습니다.' });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setMessage({ type: 'error', text: '저장 중 오류가 발생했습니다.' });
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
setTimeout(() => setMessage(null), 3000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<div className="w-8 h-8 border-4 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
<div className="flex items-start justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">공통 샘플 코드 관리</h1>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
API HUB 상세 페이지에 공통으로 표시되는 샘플 코드를 관리합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
className="px-4 py-2 rounded-lg bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white text-sm font-medium transition-colors shrink-0"
|
||||||
|
>
|
||||||
|
{saving ? '저장 중...' : '저장'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{message && (
|
||||||
|
<div
|
||||||
|
className={`mb-4 p-3 rounded-lg text-sm ${
|
||||||
|
message.type === 'success'
|
||||||
|
? 'bg-green-50 dark:bg-green-900/30 text-green-700 dark:text-green-400'
|
||||||
|
: 'bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{message.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
샘플 코드
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={sampleCode}
|
||||||
|
onChange={(e) => setSampleCode(e.target.value)}
|
||||||
|
rows={20}
|
||||||
|
placeholder="API HUB 상세 페이지에 표시할 공통 샘플 코드를 입력하세요."
|
||||||
|
className={`${INPUT_CLS} resize-y`}
|
||||||
|
/>
|
||||||
|
<p className="mt-2 text-xs text-gray-400 dark:text-gray-500">
|
||||||
|
이 코드는 모든 API 상세 페이지의 '요청 URL 생성' 섹션 하단에 표시됩니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SampleCodePage;
|
||||||
@ -1,17 +1,17 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import type {
|
import type {
|
||||||
ServiceInfo,
|
ServiceInfo,
|
||||||
ServiceApi,
|
ServiceApi,
|
||||||
CreateServiceRequest,
|
CreateServiceRequest,
|
||||||
UpdateServiceRequest,
|
UpdateServiceRequest,
|
||||||
CreateServiceApiRequest,
|
|
||||||
} from '../../types/service';
|
} from '../../types/service';
|
||||||
import {
|
import {
|
||||||
getServices,
|
getServices,
|
||||||
createService,
|
createService,
|
||||||
updateService,
|
updateService,
|
||||||
|
deleteService,
|
||||||
getServiceApis,
|
getServiceApis,
|
||||||
createServiceApi,
|
|
||||||
} from '../../services/serviceService';
|
} from '../../services/serviceService';
|
||||||
|
|
||||||
const HEALTH_BADGE: Record<string, { dot: string; bg: string; text: string }> = {
|
const HEALTH_BADGE: Record<string, { dot: string; bg: string; text: string }> = {
|
||||||
@ -41,6 +41,7 @@ const formatRelativeTime = (dateStr: string | null): string => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const ServicesPage = () => {
|
const ServicesPage = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
const [services, setServices] = useState<ServiceInfo[]>([]);
|
const [services, setServices] = useState<ServiceInfo[]>([]);
|
||||||
const [selectedService, setSelectedService] = useState<ServiceInfo | null>(null);
|
const [selectedService, setSelectedService] = useState<ServiceInfo | null>(null);
|
||||||
const [serviceApis, setServiceApis] = useState<ServiceApi[]>([]);
|
const [serviceApis, setServiceApis] = useState<ServiceApi[]>([]);
|
||||||
@ -57,14 +58,6 @@ const ServicesPage = () => {
|
|||||||
const [healthCheckInterval, setHealthCheckInterval] = useState(60);
|
const [healthCheckInterval, setHealthCheckInterval] = useState(60);
|
||||||
const [serviceIsActive, setServiceIsActive] = useState(true);
|
const [serviceIsActive, setServiceIsActive] = useState(true);
|
||||||
|
|
||||||
const [isApiModalOpen, setIsApiModalOpen] = useState(false);
|
|
||||||
const [apiMethod, setApiMethod] = useState('GET');
|
|
||||||
const [apiPath, setApiPath] = useState('');
|
|
||||||
const [apiName, setApiName] = useState('');
|
|
||||||
const [apiDomain, setApiDomain] = useState('');
|
|
||||||
const [apiSection, setApiSection] = useState('');
|
|
||||||
const [apiDescription, setApiDescription] = useState('');
|
|
||||||
|
|
||||||
const fetchServices = async () => {
|
const fetchServices = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@ -97,8 +90,13 @@ const ServicesPage = () => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSelectService = (service: ServiceInfo) => {
|
const handleSelectService = (service: ServiceInfo) => {
|
||||||
|
if (selectedService?.serviceId === service.serviceId) {
|
||||||
|
setSelectedService(null);
|
||||||
|
setServiceApis([]);
|
||||||
|
} else {
|
||||||
setSelectedService(service);
|
setSelectedService(service);
|
||||||
fetchApis(service.serviceId);
|
fetchApis(service.serviceId);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenCreateService = () => {
|
const handleOpenCreateService = () => {
|
||||||
@ -172,44 +170,21 @@ const ServicesPage = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenCreateApi = () => {
|
const handleDeleteService = async (service: ServiceInfo) => {
|
||||||
setApiMethod('GET');
|
if (!window.confirm(`'${service.serviceName}' 서비스를 삭제하시겠습니까?`)) return;
|
||||||
setApiPath('');
|
|
||||||
setApiName('');
|
|
||||||
setApiDomain('');
|
|
||||||
setApiSection('');
|
|
||||||
setApiDescription('');
|
|
||||||
setIsApiModalOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCloseApiModal = () => {
|
|
||||||
setIsApiModalOpen(false);
|
|
||||||
setError(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleApiSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!selectedService) return;
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const req: CreateServiceApiRequest = {
|
const res = await deleteService(service.serviceId);
|
||||||
apiMethod,
|
|
||||||
apiPath,
|
|
||||||
apiName,
|
|
||||||
apiDomain: apiDomain || undefined,
|
|
||||||
apiSection: apiSection || undefined,
|
|
||||||
description: apiDescription || undefined,
|
|
||||||
};
|
|
||||||
const res = await createServiceApi(selectedService.serviceId, req);
|
|
||||||
if (!res.success) {
|
if (!res.success) {
|
||||||
setError(res.message || 'API 생성에 실패했습니다.');
|
setError(res.message || '서비스 삭제에 실패했습니다.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
handleCloseApiModal();
|
if (selectedService?.serviceId === service.serviceId) {
|
||||||
await fetchApis(selectedService.serviceId);
|
setSelectedService(null);
|
||||||
|
setServiceApis([]);
|
||||||
|
}
|
||||||
|
await fetchServices();
|
||||||
} catch {
|
} catch {
|
||||||
setError('API 생성에 실패했습니다.');
|
setError('서비스 삭제에 실패했습니다.');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -229,7 +204,7 @@ const ServicesPage = () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && !isServiceModalOpen && !isApiModalOpen && (
|
{error && !isServiceModalOpen && (
|
||||||
<div className="mb-4 p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
|
<div className="mb-4 p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -292,6 +267,7 @@ const ServicesPage = () => {
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@ -301,6 +277,16 @@ const ServicesPage = () => {
|
|||||||
>
|
>
|
||||||
수정
|
수정
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleDeleteService(service);
|
||||||
|
}}
|
||||||
|
className="bg-red-100 hover:bg-red-200 text-red-700 dark:bg-red-900/30 dark:hover:bg-red-800/40 dark:text-red-400 px-3 py-1 rounded-lg text-sm font-medium"
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
@ -323,10 +309,10 @@ const ServicesPage = () => {
|
|||||||
APIs for {selectedService.serviceName}
|
APIs for {selectedService.serviceName}
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
onClick={handleOpenCreateApi}
|
onClick={() => navigate('/admin/apis')}
|
||||||
className="bg-blue-600 hover:bg-blue-700 text-white px-3 py-1.5 rounded-lg text-sm font-medium"
|
className="bg-blue-600 hover:bg-blue-700 text-white px-3 py-1.5 rounded-lg text-sm font-medium"
|
||||||
>
|
>
|
||||||
Add API
|
API 관리
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
@ -336,6 +322,8 @@ const ServicesPage = () => {
|
|||||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Method</th>
|
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Method</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Path</th>
|
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Path</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Name</th>
|
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Name</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Domain</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Section</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Description</th>
|
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Description</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Active</th>
|
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Active</th>
|
||||||
</tr>
|
</tr>
|
||||||
@ -354,6 +342,8 @@ const ServicesPage = () => {
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 font-mono text-gray-900 dark:text-gray-100">{api.apiPath}</td>
|
<td className="px-4 py-3 font-mono text-gray-900 dark:text-gray-100">{api.apiPath}</td>
|
||||||
<td className="px-4 py-3 text-gray-900 dark:text-gray-100">{api.apiName}</td>
|
<td className="px-4 py-3 text-gray-900 dark:text-gray-100">{api.apiName}</td>
|
||||||
|
<td className="px-4 py-3 text-gray-500 dark:text-gray-400">{api.apiDomain || '-'}</td>
|
||||||
|
<td className="px-4 py-3 text-gray-500 dark:text-gray-400">{api.apiSection || '-'}</td>
|
||||||
<td className="px-4 py-3 text-gray-500 dark:text-gray-400">{api.description || '-'}</td>
|
<td className="px-4 py-3 text-gray-500 dark:text-gray-400">{api.description || '-'}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<span
|
<span
|
||||||
@ -370,7 +360,7 @@ const ServicesPage = () => {
|
|||||||
))}
|
))}
|
||||||
{serviceApis.length === 0 && (
|
{serviceApis.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={5} className="px-4 py-8 text-center text-gray-400 dark:text-gray-500">
|
<td colSpan={7} className="px-4 py-8 text-center text-gray-400 dark:text-gray-500">
|
||||||
등록된 API가 없습니다.
|
등록된 API가 없습니다.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -499,103 +489,6 @@ const ServicesPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isApiModalOpen && (
|
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md mx-4">
|
|
||||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
|
||||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">API 생성</h2>
|
|
||||||
</div>
|
|
||||||
<form onSubmit={handleApiSubmit}>
|
|
||||||
<div className="px-6 py-4 space-y-4">
|
|
||||||
{error && (
|
|
||||||
<div className="p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
|
|
||||||
)}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Method</label>
|
|
||||||
<select
|
|
||||||
value={apiMethod}
|
|
||||||
onChange={(e) => setApiMethod(e.target.value)}
|
|
||||||
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
|
||||||
>
|
|
||||||
<option value="GET">GET</option>
|
|
||||||
<option value="POST">POST</option>
|
|
||||||
<option value="PUT">PUT</option>
|
|
||||||
<option value="DELETE">DELETE</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">API Path</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={apiPath}
|
|
||||||
onChange={(e) => setApiPath(e.target.value)}
|
|
||||||
required
|
|
||||||
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">API Name</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={apiName}
|
|
||||||
onChange={(e) => setApiName(e.target.value)}
|
|
||||||
required
|
|
||||||
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">도메인</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={apiDomain}
|
|
||||||
onChange={(e) => setApiDomain(e.target.value)}
|
|
||||||
placeholder="예: Compliance"
|
|
||||||
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">섹션</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={apiSection}
|
|
||||||
onChange={(e) => setApiSection(e.target.value)}
|
|
||||||
placeholder="예: 선박 규정"
|
|
||||||
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
||||||
Description
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
value={apiDescription}
|
|
||||||
onChange={(e) => setApiDescription(e.target.value)}
|
|
||||||
rows={3}
|
|
||||||
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleCloseApiModal}
|
|
||||||
className="bg-gray-200 hover:bg-gray-300 dark:bg-gray-600 dark:hover:bg-gray-500 text-gray-700 dark:text-gray-200 px-4 py-2 rounded-lg text-sm font-medium"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium"
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
846
frontend/src/pages/apihub/ApiHubApiDetailPage.tsx
Normal file
846
frontend/src/pages/apihub/ApiHubApiDetailPage.tsx
Normal file
@ -0,0 +1,846 @@
|
|||||||
|
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import type { ServiceCatalog } from '../../types/apihub';
|
||||||
|
import type { ApiDetailInfo } from '../../types/service';
|
||||||
|
import { getCatalog, getServiceCatalog, getApiHubApiDetail } from '../../services/apiHubService';
|
||||||
|
import { getSystemConfig } from '../../services/configService';
|
||||||
|
import { createKeyRequest } from '../../services/apiKeyService';
|
||||||
|
|
||||||
|
const COMMON_SAMPLE_CODE_KEY = 'COMMON_SAMPLE_CODE';
|
||||||
|
|
||||||
|
const METHOD_COLORS_LARGE: Record<string, string> = {
|
||||||
|
GET: 'bg-green-500',
|
||||||
|
POST: 'bg-blue-500',
|
||||||
|
PUT: 'bg-amber-500',
|
||||||
|
PATCH: 'bg-amber-500',
|
||||||
|
DELETE: 'bg-red-500',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ApiHubApiDetailPage = () => {
|
||||||
|
const { serviceId, apiId } = useParams<{ serviceId: string; apiId: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [detail, setDetail] = useState<ApiDetailInfo | null>(null);
|
||||||
|
const [service, setService] = useState<ServiceCatalog | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [paramInputs, setParamInputs] = useState<Record<string, string>>({});
|
||||||
|
const [generatedUrl, setGeneratedUrl] = useState<string | null>(null);
|
||||||
|
const [urlCopied, setUrlCopied] = useState(false);
|
||||||
|
const [validationErrors, setValidationErrors] = useState<Record<string, boolean>>({});
|
||||||
|
const [commonSampleCode, setCommonSampleCode] = useState<string | null>(null);
|
||||||
|
const [urlGenOpen, setUrlGenOpen] = useState(false);
|
||||||
|
|
||||||
|
// 신청 모달 상태
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [modalKeyName, setModalKeyName] = useState('');
|
||||||
|
const [modalPurpose, setModalPurpose] = useState('');
|
||||||
|
const [modalServiceIp, setModalServiceIp] = useState('');
|
||||||
|
const [modalServicePurpose, setModalServicePurpose] = useState('');
|
||||||
|
const [modalDailyRequestEstimate, setModalDailyRequestEstimate] = useState('');
|
||||||
|
const [modalUsagePeriodMode, setModalUsagePeriodMode] = useState<'preset' | 'custom'>('preset');
|
||||||
|
const [modalIsPermanent, setModalIsPermanent] = useState(false);
|
||||||
|
const [modalUsageFromDate, setModalUsageFromDate] = useState('');
|
||||||
|
const [modalUsageToDate, setModalUsageToDate] = useState('');
|
||||||
|
const [modalIsSubmitting, setModalIsSubmitting] = useState(false);
|
||||||
|
const [modalError, setModalError] = useState<string | null>(null);
|
||||||
|
const [modalSuccess, setModalSuccess] = useState(false);
|
||||||
|
const [modalSelectedApiIds, setModalSelectedApiIds] = useState<Set<number>>(new Set());
|
||||||
|
const [modalCatalog, setModalCatalog] = useState<ServiceCatalog[]>([]);
|
||||||
|
const [modalExpandedDomains, setModalExpandedDomains] = useState<Set<string>>(new Set());
|
||||||
|
const [modalApiSearch, setModalApiSearch] = useState('');
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
if (!serviceId || !apiId) return;
|
||||||
|
try {
|
||||||
|
const [serviceRes, detailRes, sampleCodeRes] = await Promise.all([
|
||||||
|
getServiceCatalog(Number(serviceId)),
|
||||||
|
getApiHubApiDetail(Number(serviceId), Number(apiId)),
|
||||||
|
getSystemConfig(COMMON_SAMPLE_CODE_KEY),
|
||||||
|
]);
|
||||||
|
if (serviceRes.success && serviceRes.data) {
|
||||||
|
setService(serviceRes.data);
|
||||||
|
}
|
||||||
|
if (detailRes.success && detailRes.data) {
|
||||||
|
setDetail(detailRes.data);
|
||||||
|
} else {
|
||||||
|
setError('API 정보를 불러오지 못했습니다');
|
||||||
|
}
|
||||||
|
if (sampleCodeRes.success && sampleCodeRes.data?.configValue) {
|
||||||
|
setCommonSampleCode(sampleCodeRes.data.configValue);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError('API 정보를 불러오는 중 오류가 발생했습니다');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [serviceId, apiId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setParamInputs({});
|
||||||
|
setGeneratedUrl(null);
|
||||||
|
setUrlCopied(false);
|
||||||
|
setValidationErrors({});
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
fetchData();
|
||||||
|
}, [fetchData]);
|
||||||
|
|
||||||
|
const urlInputParams = useMemo(() =>
|
||||||
|
detail?.requestParams.filter((p) => p.paramName.toUpperCase() !== 'URL') ?? [],
|
||||||
|
[detail?.requestParams]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="text-gray-500 dark:text-gray-400">로딩 중...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !detail) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/api-hub/services/${serviceId}`)}
|
||||||
|
className="text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 mb-4 inline-block"
|
||||||
|
>
|
||||||
|
← 서비스로
|
||||||
|
</button>
|
||||||
|
<div className="text-center py-20 text-gray-500 dark:text-gray-400">
|
||||||
|
{error ?? 'API를 찾을 수 없습니다'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const api = detail.api;
|
||||||
|
const spec = detail.spec;
|
||||||
|
const domainLabel = api.apiDomain ? api.apiDomain.toUpperCase() : '기타';
|
||||||
|
|
||||||
|
const handleGenerateUrl = () => {
|
||||||
|
const errors: Record<string, boolean> = {};
|
||||||
|
for (const p of urlInputParams) {
|
||||||
|
if (p.required && !paramInputs[p.paramName]?.trim()) {
|
||||||
|
errors[p.paramName] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setValidationErrors(errors);
|
||||||
|
if (Object.keys(errors).length > 0) return;
|
||||||
|
|
||||||
|
const base = spec?.sampleUrl || (service?.serviceUrl ? `${service.serviceUrl}${api.apiPath}` : api.apiPath);
|
||||||
|
const queryParts: string[] = [];
|
||||||
|
for (const p of urlInputParams) {
|
||||||
|
const val = paramInputs[p.paramName];
|
||||||
|
if (val) {
|
||||||
|
const type = (p.inputType || 'TEXT').toUpperCase();
|
||||||
|
let formatted = val;
|
||||||
|
if (type === 'DATE' || type === 'DATETIME') {
|
||||||
|
formatted = new Date(val).toISOString();
|
||||||
|
}
|
||||||
|
queryParts.push(`${p.paramName}=${formatted}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const url = queryParts.length > 0
|
||||||
|
? `${base}${base.includes('?') ? '&' : '?'}${queryParts.join('&')}`
|
||||||
|
: base;
|
||||||
|
setGeneratedUrl(url);
|
||||||
|
setUrlCopied(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopyGeneratedUrl = () => {
|
||||||
|
if (generatedUrl) {
|
||||||
|
navigator.clipboard.writeText(generatedUrl);
|
||||||
|
setUrlCopied(true);
|
||||||
|
setTimeout(() => setUrlCopied(false), 2000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenModal = async () => {
|
||||||
|
setModalKeyName('');
|
||||||
|
setModalPurpose('');
|
||||||
|
setModalServiceIp('');
|
||||||
|
setModalServicePurpose('');
|
||||||
|
setModalDailyRequestEstimate('');
|
||||||
|
setModalUsagePeriodMode('preset');
|
||||||
|
setModalIsPermanent(false);
|
||||||
|
setModalUsageFromDate('');
|
||||||
|
setModalUsageToDate('');
|
||||||
|
setModalError(null);
|
||||||
|
setModalSuccess(false);
|
||||||
|
setModalApiSearch('');
|
||||||
|
if (detail) {
|
||||||
|
setModalSelectedApiIds(new Set([detail.api.apiId]));
|
||||||
|
// 현재 API의 도메인을 펼침
|
||||||
|
const currentDomain = detail.api.apiDomain ?? '';
|
||||||
|
setModalExpandedDomains(new Set([currentDomain.toUpperCase()]));
|
||||||
|
}
|
||||||
|
setIsModalOpen(true);
|
||||||
|
if (modalCatalog.length === 0) {
|
||||||
|
const res = await getCatalog();
|
||||||
|
if (res.data) setModalCatalog(res.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseModal = () => {
|
||||||
|
setIsModalOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleModalPresetPeriod = (months: number) => {
|
||||||
|
const from = new Date();
|
||||||
|
const to = new Date();
|
||||||
|
to.setMonth(to.getMonth() + months);
|
||||||
|
setModalUsageFromDate(from.toISOString().split('T')[0]);
|
||||||
|
setModalUsageToDate(to.toISOString().split('T')[0]);
|
||||||
|
setModalUsagePeriodMode('preset');
|
||||||
|
setModalIsPermanent(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleModalPermanent = () => {
|
||||||
|
setModalIsPermanent(true);
|
||||||
|
setModalUsageFromDate('');
|
||||||
|
setModalUsageToDate('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleModalSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (modalSelectedApiIds.size === 0) {
|
||||||
|
setModalError('최소 하나의 API를 선택해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!modalIsPermanent && (!modalUsageFromDate || !modalUsageToDate)) {
|
||||||
|
setModalError('사용 기간을 설정해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setModalError(null);
|
||||||
|
setModalIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
const res = await createKeyRequest({
|
||||||
|
keyName: modalKeyName,
|
||||||
|
purpose: modalPurpose || undefined,
|
||||||
|
requestedApiIds: Array.from(modalSelectedApiIds),
|
||||||
|
serviceIp: modalServiceIp || undefined,
|
||||||
|
servicePurpose: modalServicePurpose || undefined,
|
||||||
|
dailyRequestEstimate: modalDailyRequestEstimate ? Number(modalDailyRequestEstimate) : undefined,
|
||||||
|
usageFromDate: modalIsPermanent ? undefined : modalUsageFromDate,
|
||||||
|
usageToDate: modalIsPermanent ? undefined : modalUsageToDate,
|
||||||
|
});
|
||||||
|
if (res.success) {
|
||||||
|
setModalSuccess(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
setIsModalOpen(false);
|
||||||
|
}, 2000);
|
||||||
|
} else {
|
||||||
|
setModalError(res.message || 'API Key 신청에 실패했습니다.');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setModalError('API Key 신청에 실패했습니다.');
|
||||||
|
} finally {
|
||||||
|
setModalIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
{/* Breadcrumb */}
|
||||||
|
<nav className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 mb-4">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/api-hub')}
|
||||||
|
className="hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
||||||
|
>
|
||||||
|
API HUB
|
||||||
|
</button>
|
||||||
|
<span>/</span>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/api-hub/domains/${encodeURIComponent(domainLabel)}`)}
|
||||||
|
className="hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
||||||
|
>
|
||||||
|
{domainLabel}
|
||||||
|
</button>
|
||||||
|
<span>/</span>
|
||||||
|
<span className="text-gray-900 dark:text-gray-100 font-medium truncate max-w-xs">{api.apiName}</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Header Card */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 mb-6 border border-gray-100 dark:border-gray-700">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span
|
||||||
|
className={`px-3 py-1.5 rounded text-sm font-bold uppercase text-white shrink-0 ${METHOD_COLORS_LARGE[api.apiMethod] ?? 'bg-gray-500'}`}
|
||||||
|
>
|
||||||
|
{api.apiMethod}
|
||||||
|
</span>
|
||||||
|
<h1 className="text-xl font-bold text-gray-900 dark:text-gray-100">{api.apiName}</h1>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleOpenModal}
|
||||||
|
className="shrink-0 px-4 py-2 rounded-lg bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
API 사용 신청
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
|
||||||
|
{/* 기본 정보 */}
|
||||||
|
{api.description && (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 border border-gray-100 dark:border-gray-700">
|
||||||
|
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100 mb-4">기본 정보</h2>
|
||||||
|
<p className="text-sm text-gray-700 dark:text-gray-300 leading-relaxed whitespace-pre-wrap">{api.description}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 샘플 URL */}
|
||||||
|
{spec?.sampleUrl && (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 border border-gray-100 dark:border-gray-700">
|
||||||
|
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100 mb-3">샘플 URL</h2>
|
||||||
|
<a
|
||||||
|
href={spec.sampleUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 break-all underline underline-offset-2 font-mono"
|
||||||
|
>
|
||||||
|
{spec.sampleUrl}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 요청 URL 생성 (아코디언) */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-100 dark:border-gray-700">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setUrlGenOpen((v) => !v)}
|
||||||
|
className="w-full flex items-center justify-between px-6 py-4 text-left"
|
||||||
|
>
|
||||||
|
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100">요청 URL 생성</h2>
|
||||||
|
<svg
|
||||||
|
className={`w-5 h-5 text-gray-500 dark:text-gray-400 transition-transform duration-200 ${urlGenOpen ? 'rotate-180' : ''}`}
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{urlGenOpen && (
|
||||||
|
<div className="px-6 pb-6 border-t border-gray-100 dark:border-gray-700 pt-4">
|
||||||
|
{/* URL 생성 폼 */}
|
||||||
|
{urlInputParams.length > 0 && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="space-y-3 mb-4">
|
||||||
|
{urlInputParams.map((p) => {
|
||||||
|
const hasError = validationErrors[p.paramName];
|
||||||
|
const inputCls = `flex-1 border ${hasError ? 'border-red-400 dark:border-red-500' : 'border-gray-300 dark:border-gray-600'} bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-1.5 text-sm focus:ring-1 focus:ring-blue-500 focus:outline-none`;
|
||||||
|
const type = (p.inputType || 'TEXT').toUpperCase();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={p.paramName}>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<label className="w-36 shrink-0 text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||||
|
{p.paramName}
|
||||||
|
{p.required && <span className="text-red-500 ml-0.5">*</span>}
|
||||||
|
</label>
|
||||||
|
{type === 'DATE' || type === 'DATETIME' ? (
|
||||||
|
<input
|
||||||
|
type={type === 'DATETIME' ? 'datetime-local' : 'date'}
|
||||||
|
value={paramInputs[p.paramName] || ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
setParamInputs((prev) => ({ ...prev, [p.paramName]: e.target.value }));
|
||||||
|
if (hasError) setValidationErrors((prev) => ({ ...prev, [p.paramName]: false }));
|
||||||
|
}}
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
) : type === 'NUMBER' ? (
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={paramInputs[p.paramName] || ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
setParamInputs((prev) => ({ ...prev, [p.paramName]: e.target.value }));
|
||||||
|
if (hasError) setValidationErrors((prev) => ({ ...prev, [p.paramName]: false }));
|
||||||
|
}}
|
||||||
|
placeholder={p.paramMeaning || p.paramName}
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={paramInputs[p.paramName] || ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
setParamInputs((prev) => ({ ...prev, [p.paramName]: e.target.value }));
|
||||||
|
if (hasError) setValidationErrors((prev) => ({ ...prev, [p.paramName]: false }));
|
||||||
|
}}
|
||||||
|
placeholder={p.paramMeaning || p.paramName}
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{hasError && (
|
||||||
|
<p className="ml-[9.75rem] mt-1 text-xs text-red-500">{p.paramName}은(는) 필수입니다</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleGenerateUrl}
|
||||||
|
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
URL 생성
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 생성된 URL */}
|
||||||
|
{generatedUrl && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<p className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1.5">생성된 요청 URL</p>
|
||||||
|
<div className="flex items-center p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||||
|
<span
|
||||||
|
className={`px-2 py-1 rounded text-xs font-bold uppercase text-white shrink-0 mr-3 ${METHOD_COLORS_LARGE[api.apiMethod] ?? 'bg-gray-500'}`}
|
||||||
|
>
|
||||||
|
{api.apiMethod}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-sm text-gray-700 dark:text-gray-300 break-all flex-1">
|
||||||
|
{generatedUrl}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={handleCopyGeneratedUrl}
|
||||||
|
className="ml-2 px-2 py-0.5 text-xs bg-gray-200 dark:bg-gray-600 text-gray-600 dark:text-gray-300 rounded hover:bg-gray-300 dark:hover:bg-gray-500 transition-colors"
|
||||||
|
>
|
||||||
|
{urlCopied ? '복사됨' : '복사'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 샘플 코드 */}
|
||||||
|
{commonSampleCode && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1.5">샘플 코드</p>
|
||||||
|
<pre className="bg-gray-900 text-green-400 text-sm font-mono p-4 rounded-lg overflow-x-auto">
|
||||||
|
{commonSampleCode}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{urlInputParams.length === 0 && !commonSampleCode && (
|
||||||
|
<p className="text-sm text-gray-400 dark:text-gray-500">요청인자가 등록되지 않았습니다</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 요청인자 */}
|
||||||
|
{detail.requestParams.length > 0 && (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-100 dark:border-gray-700">
|
||||||
|
<div className="px-6 py-4 border-b border-gray-100 dark:border-gray-700">
|
||||||
|
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100">요청인자</h2>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">인자명</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">의미</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">설명</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{detail.requestParams.map((param) => (
|
||||||
|
<tr key={param.paramId} className="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
|
||||||
|
<td className="px-4 py-3 font-mono text-xs text-gray-900 dark:text-gray-100 font-medium">
|
||||||
|
{param.paramName}
|
||||||
|
{param.required && <span className="text-red-500 ml-0.5">*</span>}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-gray-700 dark:text-gray-300">
|
||||||
|
{param.paramMeaning ?? <span className="text-gray-400 dark:text-gray-500">-</span>}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-gray-600 dark:text-gray-400 max-w-xs">
|
||||||
|
{param.paramDescription ?? <span className="text-gray-400 dark:text-gray-500">-</span>}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 출력결과 */}
|
||||||
|
{detail.responseParams.length > 0 && (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-100 dark:border-gray-700">
|
||||||
|
<div className="px-6 py-4 border-b border-gray-100 dark:border-gray-700">
|
||||||
|
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100">출력결과</h2>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase w-1/4">변수명</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase w-1/4">의미(단위)</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase w-1/4">변수명</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase w-1/4">의미(단위)</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{Array.from({ length: Math.ceil(detail.responseParams.length / 2) }, (_, rowIdx) => {
|
||||||
|
const left = detail.responseParams[rowIdx * 2];
|
||||||
|
const right = detail.responseParams[rowIdx * 2 + 1];
|
||||||
|
return (
|
||||||
|
<tr key={rowIdx} className="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
|
||||||
|
<td className="px-4 py-3 font-mono text-xs text-gray-900 dark:text-gray-100 font-medium">
|
||||||
|
{left.paramName}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-gray-700 dark:text-gray-300">
|
||||||
|
{left.paramMeaning ?? <span className="text-gray-400 dark:text-gray-500">-</span>}
|
||||||
|
</td>
|
||||||
|
{right ? (
|
||||||
|
<>
|
||||||
|
<td className="px-4 py-3 font-mono text-xs text-gray-900 dark:text-gray-100 font-medium">
|
||||||
|
{right.paramName}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-gray-700 dark:text-gray-300">
|
||||||
|
{right.paramMeaning ?? <span className="text-gray-400 dark:text-gray-500">-</span>}
|
||||||
|
</td>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<td className="px-4 py-3" />
|
||||||
|
<td className="px-4 py-3" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 참고자료 */}
|
||||||
|
{spec?.referenceUrl && (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 border border-gray-100 dark:border-gray-700">
|
||||||
|
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100 mb-3">참고자료</h2>
|
||||||
|
<a
|
||||||
|
href={spec.referenceUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 break-all underline underline-offset-2"
|
||||||
|
>
|
||||||
|
{spec.referenceUrl}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 비고 */}
|
||||||
|
{spec?.note && (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 border border-gray-100 dark:border-gray-700">
|
||||||
|
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100 mb-3">비고</h2>
|
||||||
|
<p className="text-sm text-gray-700 dark:text-gray-300 leading-relaxed whitespace-pre-wrap">
|
||||||
|
{spec.note}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* API 사용 신청 모달 */}
|
||||||
|
{isModalOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4"
|
||||||
|
onClick={(e) => { if (e.target === e.currentTarget) handleCloseModal(); }}
|
||||||
|
>
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl w-full max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||||
|
{/* 모달 헤더 */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h2 className="text-lg font-bold text-gray-900 dark:text-gray-100">API 사용 신청</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCloseModal}
|
||||||
|
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 성공 메시지 */}
|
||||||
|
{modalSuccess ? (
|
||||||
|
<div className="px-6 py-10 text-center">
|
||||||
|
<div className="bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-700 rounded-lg p-6">
|
||||||
|
<h3 className="text-lg font-semibold text-green-800 dark:text-green-300 mb-2">신청이 완료되었습니다</h3>
|
||||||
|
<p className="text-green-700 dark:text-green-400 text-sm">관리자 승인 후 API Key가 생성됩니다.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleModalSubmit} className="px-6 py-5 space-y-5">
|
||||||
|
{/* 에러 메시지 */}
|
||||||
|
{modalError && (
|
||||||
|
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 text-red-700 dark:text-red-400 rounded-lg text-sm">
|
||||||
|
{modalError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Key Name */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Key Name <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={modalKeyName}
|
||||||
|
onChange={(e) => setModalKeyName(e.target.value)}
|
||||||
|
required
|
||||||
|
placeholder="API Key 이름을 입력하세요"
|
||||||
|
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 사용 목적 */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">사용 목적</label>
|
||||||
|
<textarea
|
||||||
|
value={modalPurpose}
|
||||||
|
onChange={(e) => setModalPurpose(e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
placeholder="사용 목적을 입력하세요"
|
||||||
|
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 사용 기간 */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
사용 기간 <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<button type="button" onClick={() => handleModalPresetPeriod(3)}
|
||||||
|
disabled={modalIsPermanent || modalUsagePeriodMode === 'custom'}
|
||||||
|
className={`px-3 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 ${modalIsPermanent || modalUsagePeriodMode === 'custom' ? 'opacity-40 cursor-not-allowed' : 'hover:bg-blue-50 dark:hover:bg-gray-600'}`}>
|
||||||
|
3개월
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => handleModalPresetPeriod(6)}
|
||||||
|
disabled={modalIsPermanent || modalUsagePeriodMode === 'custom'}
|
||||||
|
className={`px-3 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 ${modalIsPermanent || modalUsagePeriodMode === 'custom' ? 'opacity-40 cursor-not-allowed' : 'hover:bg-blue-50 dark:hover:bg-gray-600'}`}>
|
||||||
|
6개월
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => handleModalPresetPeriod(9)}
|
||||||
|
disabled={modalIsPermanent || modalUsagePeriodMode === 'custom'}
|
||||||
|
className={`px-3 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 ${modalIsPermanent || modalUsagePeriodMode === 'custom' ? 'opacity-40 cursor-not-allowed' : 'hover:bg-blue-50 dark:hover:bg-gray-600'}`}>
|
||||||
|
9개월
|
||||||
|
</button>
|
||||||
|
<span className="text-gray-400 dark:text-gray-600 mx-1">|</span>
|
||||||
|
<button type="button" onClick={handleModalPermanent}
|
||||||
|
className={`px-3 py-1.5 text-sm rounded-lg border font-medium ${modalIsPermanent ? 'bg-indigo-600 text-white border-indigo-600' : 'text-indigo-600 border-indigo-300 dark:border-indigo-500 dark:text-indigo-400 hover:bg-indigo-50 dark:hover:bg-indigo-900/30'}`}>
|
||||||
|
영구
|
||||||
|
</button>
|
||||||
|
<span className="text-gray-400 dark:text-gray-600 mx-1">|</span>
|
||||||
|
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer select-none">
|
||||||
|
직접 선택
|
||||||
|
<button type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setModalUsagePeriodMode(modalUsagePeriodMode === 'custom' ? 'preset' : 'custom');
|
||||||
|
setModalIsPermanent(false);
|
||||||
|
}}
|
||||||
|
className={`relative w-10 h-5 rounded-full transition-colors ${modalUsagePeriodMode === 'custom' && !modalIsPermanent ? 'bg-blue-600' : 'bg-gray-300 dark:bg-gray-600'}`}>
|
||||||
|
<span className={`absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full shadow transition-transform ${modalUsagePeriodMode === 'custom' && !modalIsPermanent ? 'translate-x-5' : ''}`} />
|
||||||
|
</button>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{modalIsPermanent ? (
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 bg-indigo-50 dark:bg-indigo-900/30 border border-indigo-200 dark:border-indigo-700 rounded-lg">
|
||||||
|
<span className="text-indigo-700 dark:text-indigo-300 text-sm font-medium">영구 사용 (만료일 없음)</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input type="date" value={modalUsageFromDate}
|
||||||
|
onChange={(e) => setModalUsageFromDate(e.target.value)}
|
||||||
|
readOnly={modalUsagePeriodMode !== 'custom'}
|
||||||
|
className={`border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none ${modalUsagePeriodMode !== 'custom' ? 'bg-gray-50 text-gray-500 dark:bg-gray-700 dark:text-gray-400' : 'bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100'}`} />
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">~</span>
|
||||||
|
<input type="date" value={modalUsageToDate}
|
||||||
|
onChange={(e) => setModalUsageToDate(e.target.value)}
|
||||||
|
readOnly={modalUsagePeriodMode !== 'custom'}
|
||||||
|
className={`border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none ${modalUsagePeriodMode !== 'custom' ? 'bg-gray-50 text-gray-500 dark:bg-gray-700 dark:text-gray-400' : 'bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100'}`} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 서비스 IP */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
서비스 IP <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={modalServiceIp}
|
||||||
|
onChange={(e) => setModalServiceIp(e.target.value)}
|
||||||
|
required
|
||||||
|
placeholder="예: 192.168.1.100"
|
||||||
|
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">발급받은 API Key로 프록시 서버에 요청하는 서비스 IP</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 서비스 용도 + 하루 예상 요청량 */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
서비스 용도 <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={modalServicePurpose}
|
||||||
|
onChange={(e) => setModalServicePurpose(e.target.value)}
|
||||||
|
required
|
||||||
|
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="">선택하세요</option>
|
||||||
|
<option value="로컬 환경">로컬 환경</option>
|
||||||
|
<option value="개발 서버">개발 서버</option>
|
||||||
|
<option value="검증 서버">검증 서버</option>
|
||||||
|
<option value="운영 서버">운영 서버</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
하루 예상 요청량 <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={modalDailyRequestEstimate}
|
||||||
|
onChange={(e) => setModalDailyRequestEstimate(e.target.value)}
|
||||||
|
required
|
||||||
|
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="">선택하세요</option>
|
||||||
|
<option value="100">100 이하</option>
|
||||||
|
<option value="500">100~500</option>
|
||||||
|
<option value="1000">500~1,000</option>
|
||||||
|
<option value="5000">1,000~5,000</option>
|
||||||
|
<option value="10000">5,000~10,000</option>
|
||||||
|
<option value="50000">10,000 이상</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* API 선택 (도메인 기반 체크박스 트리) */}
|
||||||
|
<div className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between px-4 py-2.5 bg-gray-50 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">API 선택</h3>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={modalApiSearch}
|
||||||
|
onChange={(e) => setModalApiSearch(e.target.value)}
|
||||||
|
placeholder="API 검색..."
|
||||||
|
className="bg-white dark:bg-gray-600 border border-gray-200 dark:border-gray-500 rounded-lg px-2.5 py-1 text-xs text-gray-900 dark:text-gray-100 placeholder-gray-400 focus:ring-1 focus:ring-blue-500 focus:outline-none w-40"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-56 overflow-y-auto">
|
||||||
|
{(() => {
|
||||||
|
const domainMap = new Map<string, { apiId: number; apiName: string }[]>();
|
||||||
|
for (const svc of modalCatalog) {
|
||||||
|
for (const dg of svc.domains) {
|
||||||
|
const key = /^[a-zA-Z\s\-_]+$/.test(dg.domain) ? dg.domain.toUpperCase() : dg.domain;
|
||||||
|
const existing = domainMap.get(key) ?? [];
|
||||||
|
for (const a of dg.apis) {
|
||||||
|
if (!existing.some((e) => e.apiId === a.apiId)) {
|
||||||
|
if (!modalApiSearch.trim() || a.apiName.toLowerCase().includes(modalApiSearch.toLowerCase())) {
|
||||||
|
existing.push({ apiId: a.apiId, apiName: a.apiName });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (existing.length > 0) domainMap.set(key, existing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const domains = Array.from(domainMap.entries());
|
||||||
|
if (domains.length === 0) return <p className="text-xs text-gray-400 text-center py-6">검색 결과가 없습니다</p>;
|
||||||
|
return domains.map(([domain, apis]) => {
|
||||||
|
const isExpanded = modalExpandedDomains.has(domain);
|
||||||
|
const allSelected = apis.every((a) => modalSelectedApiIds.has(a.apiId));
|
||||||
|
const someSelected = !allSelected && apis.some((a) => modalSelectedApiIds.has(a.apiId));
|
||||||
|
return (
|
||||||
|
<div key={domain}>
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2 px-4 py-2 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700/50 border-b border-gray-100 dark:border-gray-700/50"
|
||||||
|
onClick={() => setModalExpandedDomains((prev) => { const n = new Set(prev); n.has(domain) ? n.delete(domain) : n.add(domain); return n; })}
|
||||||
|
>
|
||||||
|
<svg className={`h-3.5 w-3.5 text-gray-400 transition-transform ${isExpanded ? 'rotate-90' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /></svg>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={allSelected}
|
||||||
|
ref={(el) => { if (el) el.indeterminate = someSelected; }}
|
||||||
|
onChange={() => {
|
||||||
|
setModalSelectedApiIds((prev) => {
|
||||||
|
const n = new Set(prev);
|
||||||
|
apis.forEach((a) => allSelected ? n.delete(a.apiId) : n.add(a.apiId));
|
||||||
|
return n;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="rounded"
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100">{domain}</span>
|
||||||
|
<span className="text-xs text-gray-400">{apis.length}</span>
|
||||||
|
</div>
|
||||||
|
{isExpanded && apis.map((a) => (
|
||||||
|
<div
|
||||||
|
key={a.apiId}
|
||||||
|
className={`flex items-center gap-2 pl-12 pr-4 py-2 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700/30 border-b border-gray-50 dark:border-gray-700/30 ${modalSelectedApiIds.has(a.apiId) ? 'bg-blue-50/50 dark:bg-blue-900/10' : ''}`}
|
||||||
|
onClick={() => setModalSelectedApiIds((prev) => { const n = new Set(prev); n.has(a.apiId) ? n.delete(a.apiId) : n.add(a.apiId); return n; })}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={modalSelectedApiIds.has(a.apiId)}
|
||||||
|
onChange={() => setModalSelectedApiIds((prev) => { const n = new Set(prev); n.has(a.apiId) ? n.delete(a.apiId) : n.add(a.apiId); return n; })}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="rounded"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-900 dark:text-gray-100 truncate">{a.apiName}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 신청 API 요약 */}
|
||||||
|
{modalSelectedApiIds.size > 0 && (
|
||||||
|
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-700 rounded-lg px-4 py-3">
|
||||||
|
<p className="text-xs font-medium text-blue-600 dark:text-blue-400 uppercase tracking-wide">
|
||||||
|
신청 API ({modalSelectedApiIds.size}개 선택)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 모달 하단 버튼 */}
|
||||||
|
<div className="flex justify-end gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCloseModal}
|
||||||
|
className="px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={modalIsSubmitting}
|
||||||
|
className="px-4 py-2 rounded-lg bg-blue-600 hover:bg-blue-700 disabled:bg-blue-300 text-white text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
{modalIsSubmitting ? '신청 중...' : '신청하기'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ApiHubApiDetailPage;
|
||||||
345
frontend/src/pages/apihub/ApiHubDashboardPage.tsx
Normal file
345
frontend/src/pages/apihub/ApiHubDashboardPage.tsx
Normal file
@ -0,0 +1,345 @@
|
|||||||
|
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import type { RecentApi, PopularApi, ServiceCatalog } from '../../types/apihub';
|
||||||
|
import { getRecentApis, getPopularApis, getCatalog } from '../../services/apiHubService';
|
||||||
|
|
||||||
|
const formatDomain = (d: string) => (/^[a-zA-Z\s\-_]+$/.test(d) ? d.toUpperCase() : d);
|
||||||
|
|
||||||
|
// 도메인 컬러 팔레트 (해시 기반 매핑)
|
||||||
|
const DOMAIN_COLOR_PALETTE = [
|
||||||
|
{ color: 'text-emerald-400', bg: 'bg-emerald-500/10', border: 'border-emerald-500/30', line: 'from-emerald-500' },
|
||||||
|
{ color: 'text-rose-400', bg: 'bg-rose-500/10', border: 'border-rose-500/30', line: 'from-rose-500' },
|
||||||
|
{ color: 'text-blue-400', bg: 'bg-blue-500/10', border: 'border-blue-500/30', line: 'from-blue-500' },
|
||||||
|
{ color: 'text-amber-400', bg: 'bg-amber-500/10', border: 'border-amber-500/30', line: 'from-amber-500' },
|
||||||
|
{ color: 'text-violet-400', bg: 'bg-violet-500/10', border: 'border-violet-500/30', line: 'from-violet-500' },
|
||||||
|
{ color: 'text-cyan-400', bg: 'bg-cyan-500/10', border: 'border-cyan-500/30', line: 'from-cyan-500' },
|
||||||
|
{ color: 'text-orange-400', bg: 'bg-orange-500/10', border: 'border-orange-500/30', line: 'from-orange-500' },
|
||||||
|
{ color: 'text-pink-400', bg: 'bg-pink-500/10', border: 'border-pink-500/30', line: 'from-pink-500' },
|
||||||
|
{ color: 'text-lime-400', bg: 'bg-lime-500/10', border: 'border-lime-500/30', line: 'from-lime-500' },
|
||||||
|
{ color: 'text-indigo-400', bg: 'bg-indigo-500/10', border: 'border-indigo-500/30', line: 'from-indigo-500' },
|
||||||
|
{ color: 'text-teal-400', bg: 'bg-teal-500/10', border: 'border-teal-500/30', line: 'from-teal-500' },
|
||||||
|
{ color: 'text-fuchsia-400', bg: 'bg-fuchsia-500/10', border: 'border-fuchsia-500/30', line: 'from-fuchsia-500' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const DEFAULT_ICON_PATHS = [
|
||||||
|
'M3.75 9.776c.112-.017.227-.026.344-.026h15.812c.117 0 .232.009.344.026m-16.5 0a2.25 2.25 0 00-1.883 2.542l.857 6a2.25 2.25 0 002.227 1.932H19.05a2.25 2.25 0 002.227-1.932l.857-6a2.25 2.25 0 00-1.883-2.542m-16.5 0V6A2.25 2.25 0 016 3.75h3.879a1.5 1.5 0 011.06.44l2.122 2.12a1.5 1.5 0 001.06.44H18A2.25 2.25 0 0120.25 9v.776',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** iconPath 문자열에서 SVG path d 값 배열을 추출 */
|
||||||
|
const parseIconPaths = (iconPath: string | null): string[] => {
|
||||||
|
if (!iconPath) return DEFAULT_ICON_PATHS;
|
||||||
|
const pathRegex = /d="([^"]+)"/g;
|
||||||
|
const matches: string[] = [];
|
||||||
|
let m;
|
||||||
|
while ((m = pathRegex.exec(iconPath)) !== null) {
|
||||||
|
matches.push(m[1]);
|
||||||
|
}
|
||||||
|
return matches.length > 0 ? matches : [iconPath];
|
||||||
|
};
|
||||||
|
|
||||||
|
const domainColorCache = new Map<string, (typeof DOMAIN_COLOR_PALETTE)[0]>();
|
||||||
|
let nextColorIdx = 0;
|
||||||
|
|
||||||
|
const getDomainColorByHash = (domain: string) => {
|
||||||
|
const key = domain.toUpperCase();
|
||||||
|
const cached = domainColorCache.get(key);
|
||||||
|
if (cached) return cached;
|
||||||
|
const color = DOMAIN_COLOR_PALETTE[nextColorIdx % DOMAIN_COLOR_PALETTE.length];
|
||||||
|
nextColorIdx++;
|
||||||
|
domainColorCache.set(key, color);
|
||||||
|
return color;
|
||||||
|
};
|
||||||
|
|
||||||
|
const RANK_BADGE_STYLES = [
|
||||||
|
'bg-gradient-to-br from-yellow-400 to-amber-500',
|
||||||
|
'bg-gradient-to-br from-gray-300 to-gray-400',
|
||||||
|
'bg-gradient-to-br from-amber-600 to-amber-700',
|
||||||
|
];
|
||||||
|
|
||||||
|
const extractSettled = <T,>(result: PromiseSettledResult<{ data?: T }>, fallback: T): T => {
|
||||||
|
if (result.status === 'fulfilled' && result.value.data !== undefined) {
|
||||||
|
return result.value.data;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
const truncate = (str: string, max: number): string =>
|
||||||
|
str.length > max ? str.slice(0, max) + '...' : str;
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string): string => {
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface FlatDomain {
|
||||||
|
domain: string;
|
||||||
|
iconPath: string | null;
|
||||||
|
sortOrder: number;
|
||||||
|
apiCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ApiHubDashboardPage = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [catalog, setCatalog] = useState<ServiceCatalog[]>([]);
|
||||||
|
const [recentApis, setRecentApis] = useState<RecentApi[]>([]);
|
||||||
|
const [popularApis, setPopularApis] = useState<PopularApi[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
const fetchAll = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const [catalogRes, recentRes, popularRes] = await Promise.allSettled([
|
||||||
|
getCatalog(),
|
||||||
|
getRecentApis(),
|
||||||
|
getPopularApis(),
|
||||||
|
]);
|
||||||
|
setCatalog(extractSettled<ServiceCatalog[]>(catalogRes, []));
|
||||||
|
setRecentApis(extractSettled<RecentApi[]>(recentRes, []));
|
||||||
|
setPopularApis(extractSettled<PopularApi[]>(popularRes, []));
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAll();
|
||||||
|
}, [fetchAll]);
|
||||||
|
|
||||||
|
// 카탈로그에서 도메인 기준으로 플랫하게 집계
|
||||||
|
const domainList = useMemo<FlatDomain[]>(() => {
|
||||||
|
const map = new Map<string, { iconPath: string | null; sortOrder: number; apiCount: number }>();
|
||||||
|
for (const svc of catalog) {
|
||||||
|
for (const dg of svc.domains) {
|
||||||
|
const key = dg.domain.toUpperCase();
|
||||||
|
const existing = map.get(key);
|
||||||
|
const apiCount = (existing?.apiCount ?? 0) + dg.apis.length;
|
||||||
|
const iconPath = existing?.iconPath !== undefined ? existing.iconPath : (dg.iconPath ?? null);
|
||||||
|
const sortOrder =
|
||||||
|
existing?.sortOrder !== undefined ? existing.sortOrder : (dg.sortOrder ?? Number.MAX_SAFE_INTEGER);
|
||||||
|
map.set(key, { iconPath, sortOrder, apiCount });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(map.entries())
|
||||||
|
.map(([domain, { iconPath, sortOrder, apiCount }]) => ({ domain, iconPath, sortOrder, apiCount }))
|
||||||
|
.sort((a, b) => a.sortOrder - b.sortOrder || a.domain.localeCompare(b.domain));
|
||||||
|
}, [catalog]);
|
||||||
|
|
||||||
|
const recentTop3 = recentApis.slice(0, 3);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="text-gray-500 dark:text-gray-400">로딩 중...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-7xl mx-auto space-y-8">
|
||||||
|
{/* 히어로 배너 */}
|
||||||
|
<div className="relative overflow-hidden rounded-2xl bg-gradient-to-br from-indigo-950 via-indigo-800 to-indigo-600 p-8">
|
||||||
|
{/* 장식 글로우 원 */}
|
||||||
|
<div className="pointer-events-none absolute -right-16 -top-16 h-64 w-64 rounded-full bg-indigo-400 opacity-20 blur-3xl" />
|
||||||
|
<div className="pointer-events-none absolute right-32 -top-8 h-32 w-32 rounded-full bg-purple-400 opacity-10 blur-2xl" />
|
||||||
|
|
||||||
|
{/* 제목 */}
|
||||||
|
<h1 className="mb-2 text-4xl font-extrabold tracking-tight text-white">S&P API HUB</h1>
|
||||||
|
<p className="text-indigo-200">S&P 해양/선박 세계데이터를 직접 만나보세요.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 인기 API 섹션 */}
|
||||||
|
{popularApis.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="mb-4 flex items-center gap-2">
|
||||||
|
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-amber-500/20">
|
||||||
|
<svg className="h-4 w-4 text-amber-400" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path fillRule="evenodd" d="M12.963 2.286a.75.75 0 00-1.071-.136 9.742 9.742 0 00-3.539 6.176 7.547 7.547 0 01-1.705-1.715.75.75 0 00-1.152-.082A9 9 0 1015.68 4.534a7.46 7.46 0 01-2.717-2.248zM15.75 14.25a3.75 3.75 0 11-7.313-1.172c.628.465 1.35.81 2.133 1a5.99 5.99 0 011.925-3.546 3.75 3.75 0 013.255 3.718z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">인기 API</h2>
|
||||||
|
<span className="ml-1 text-xs text-gray-400 dark:text-gray-500">최근 7일 기준</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
|
{popularApis.map((api, idx) => {
|
||||||
|
const palette = api.domain ? getDomainColorByHash(api.domain) : DOMAIN_COLOR_PALETTE[4];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="group flex flex-col rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 shadow-sm cursor-pointer transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md hover:border-indigo-400/50 dark:hover:border-indigo-500/50"
|
||||||
|
onClick={() =>
|
||||||
|
api.serviceId && api.apiId
|
||||||
|
? navigate(`/api-hub/services/${api.serviceId}/apis/${api.apiId}`)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="mb-3 flex items-center gap-2">
|
||||||
|
{/* 랭킹 뱃지 */}
|
||||||
|
<div
|
||||||
|
className={`flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-lg text-xs font-bold text-white shadow ${RANK_BADGE_STYLES[idx] ?? 'bg-gray-500'}`}
|
||||||
|
>
|
||||||
|
{idx + 1}
|
||||||
|
</div>
|
||||||
|
{api.domain && (
|
||||||
|
<span
|
||||||
|
className={`rounded-full px-2 py-0.5 text-xs font-medium truncate ${palette.bg} ${palette.color}`}
|
||||||
|
>
|
||||||
|
{formatDomain(api.domain)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
className="flex-1 text-sm font-semibold text-gray-900 dark:text-gray-100 truncate mb-4"
|
||||||
|
title={api.apiName}
|
||||||
|
>
|
||||||
|
{api.apiName}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-end justify-between border-t border-gray-100 dark:border-gray-700 pt-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-400 dark:text-gray-500 mb-0.5">주간 호출</p>
|
||||||
|
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
{api.count.toLocaleString()}
|
||||||
|
<span className="ml-0.5 text-xs font-normal text-gray-400 dark:text-gray-500">회</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<svg className="h-7 w-10 text-indigo-400/60" fill="none" viewBox="0 0 40 28" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<polyline points="2,22 8,16 14,20 22,8 28,14 36,4" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 최신 등록 API 섹션 */}
|
||||||
|
<div>
|
||||||
|
<div className="mb-4 flex items-center gap-2">
|
||||||
|
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-cyan-500/20">
|
||||||
|
<svg className="h-4 w-4 text-cyan-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.455 2.456L21.75 6l-1.036.259a3.375 3.375 0 00-2.455 2.456z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">최신 등록 API</h2>
|
||||||
|
</div>
|
||||||
|
{recentTop3.length > 0 ? (
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
|
{recentTop3.map((api) => {
|
||||||
|
const palette = api.apiDomain ? getDomainColorByHash(api.apiDomain) : DOMAIN_COLOR_PALETTE[4];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={api.apiId}
|
||||||
|
className="group flex flex-col rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 shadow-sm cursor-pointer transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md hover:border-indigo-400/50 dark:hover:border-indigo-500/50"
|
||||||
|
onClick={() => navigate(`/api-hub/services/${api.serviceId}/apis/${api.apiId}`)}
|
||||||
|
>
|
||||||
|
<div className="mb-2 flex items-center gap-2">
|
||||||
|
{api.apiDomain && (
|
||||||
|
<span
|
||||||
|
className={`rounded-full px-2 py-0.5 text-xs font-medium truncate ${palette.bg} ${palette.color}`}
|
||||||
|
>
|
||||||
|
{formatDomain(api.apiDomain)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
className="flex-1 text-sm font-semibold text-gray-900 dark:text-gray-100 mb-1 truncate"
|
||||||
|
title={api.apiName}
|
||||||
|
>
|
||||||
|
{api.apiName}
|
||||||
|
</p>
|
||||||
|
{api.description && (
|
||||||
|
<p className="mb-3 text-xs text-gray-500 dark:text-gray-400 line-clamp-2">
|
||||||
|
{truncate(api.description, 80)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-auto flex items-center gap-1.5 text-xs text-gray-400 dark:text-gray-500">
|
||||||
|
<svg className="h-3.5 w-3.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{formatDate(api.createdAt)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-8 text-center text-sm text-gray-400 dark:text-gray-500">
|
||||||
|
등록된 API가 없습니다
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* 서비스 도메인 섹션 */}
|
||||||
|
{domainList.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="mb-4 flex items-center gap-2">
|
||||||
|
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-indigo-500/20">
|
||||||
|
<svg className="h-4 w-4 text-indigo-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">서비스 도메인</h2>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{domainList.map((item) => {
|
||||||
|
const palette = getDomainColorByHash(item.domain);
|
||||||
|
const iconPaths = parseIconPaths(item.iconPath);
|
||||||
|
const imgSrc = `${import.meta.env.BASE_URL}images/domains/${item.domain.toLowerCase()}.jpg`;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.domain}
|
||||||
|
onClick={() => navigate(`/api-hub/domains/${encodeURIComponent(item.domain)}`)}
|
||||||
|
className={`group relative overflow-hidden rounded-xl border bg-white dark:bg-gray-800 ${palette.border} cursor-pointer transition-all duration-200 hover:-translate-y-1 hover:shadow-xl`}
|
||||||
|
>
|
||||||
|
<div className="relative h-[200px] overflow-hidden bg-gray-100 dark:bg-gray-700">
|
||||||
|
<img
|
||||||
|
src={imgSrc}
|
||||||
|
alt={item.domain}
|
||||||
|
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||||
|
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-x-0 bottom-0 flex items-center justify-between bg-black/20 backdrop-blur-sm px-3.5 py-2.5">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<div className={`flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-lg ${palette.bg}`}>
|
||||||
|
<svg
|
||||||
|
className={`h-4 w-4 ${palette.color}`}
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
{iconPaths.map((d, i) => (
|
||||||
|
<path key={i} d={d} />
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-semibold text-white truncate">
|
||||||
|
{formatDomain(item.domain)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="flex-shrink-0 text-xs font-semibold text-white/80">
|
||||||
|
{item.apiCount} APIs
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ApiHubDashboardPage;
|
||||||
238
frontend/src/pages/apihub/ApiHubDomainPage.tsx
Normal file
238
frontend/src/pages/apihub/ApiHubDomainPage.tsx
Normal file
@ -0,0 +1,238 @@
|
|||||||
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import type { ServiceCatalog } from '../../types/apihub';
|
||||||
|
import { getCatalog } from '../../services/apiHubService';
|
||||||
|
|
||||||
|
const DEFAULT_ICON_PATHS = [
|
||||||
|
'M3.75 9.776c.112-.017.227-.026.344-.026h15.812c.117 0 .232.009.344.026m-16.5 0a2.25 2.25 0 00-1.883 2.542l.857 6a2.25 2.25 0 002.227 1.932H19.05a2.25 2.25 0 002.227-1.932l.857-6a2.25 2.25 0 00-1.883-2.542m-16.5 0V6A2.25 2.25 0 016 3.75h3.879a1.5 1.5 0 011.06.44l2.122 2.12a1.5 1.5 0 001.06.44H18A2.25 2.25 0 0120.25 9v.776',
|
||||||
|
];
|
||||||
|
|
||||||
|
const DOMAIN_COLOR_PALETTE = [
|
||||||
|
{ color: 'text-emerald-400', bg: 'bg-emerald-500/10', border: 'border-emerald-500/30', line: 'from-emerald-500' },
|
||||||
|
{ color: 'text-rose-400', bg: 'bg-rose-500/10', border: 'border-rose-500/30', line: 'from-rose-500' },
|
||||||
|
{ color: 'text-blue-400', bg: 'bg-blue-500/10', border: 'border-blue-500/30', line: 'from-blue-500' },
|
||||||
|
{ color: 'text-amber-400', bg: 'bg-amber-500/10', border: 'border-amber-500/30', line: 'from-amber-500' },
|
||||||
|
{ color: 'text-violet-400', bg: 'bg-violet-500/10', border: 'border-violet-500/30', line: 'from-violet-500' },
|
||||||
|
{ color: 'text-cyan-400', bg: 'bg-cyan-500/10', border: 'border-cyan-500/30', line: 'from-cyan-500' },
|
||||||
|
{ color: 'text-orange-400', bg: 'bg-orange-500/10', border: 'border-orange-500/30', line: 'from-orange-500' },
|
||||||
|
{ color: 'text-pink-400', bg: 'bg-pink-500/10', border: 'border-pink-500/30', line: 'from-pink-500' },
|
||||||
|
{ color: 'text-lime-400', bg: 'bg-lime-500/10', border: 'border-lime-500/30', line: 'from-lime-500' },
|
||||||
|
{ color: 'text-indigo-400', bg: 'bg-indigo-500/10', border: 'border-indigo-500/30', line: 'from-indigo-500' },
|
||||||
|
{ color: 'text-teal-400', bg: 'bg-teal-500/10', border: 'border-teal-500/30', line: 'from-teal-500' },
|
||||||
|
{ color: 'text-fuchsia-400', bg: 'bg-fuchsia-500/10', border: 'border-fuchsia-500/30', line: 'from-fuchsia-500' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const domainColorCache = new Map<string, (typeof DOMAIN_COLOR_PALETTE)[0]>();
|
||||||
|
let nextColorIdx = 0;
|
||||||
|
|
||||||
|
const getDomainColorByHash = (domain: string) => {
|
||||||
|
const key = domain.toUpperCase();
|
||||||
|
const cached = domainColorCache.get(key);
|
||||||
|
if (cached) return cached;
|
||||||
|
const color = DOMAIN_COLOR_PALETTE[nextColorIdx % DOMAIN_COLOR_PALETTE.length];
|
||||||
|
nextColorIdx++;
|
||||||
|
domainColorCache.set(key, color);
|
||||||
|
return color;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** iconPath 문자열에서 SVG path d 값 배열을 추출 */
|
||||||
|
const parseIconPaths = (iconPath: string | null): string[] => {
|
||||||
|
if (!iconPath) return DEFAULT_ICON_PATHS;
|
||||||
|
const pathRegex = /d="([^"]+)"/g;
|
||||||
|
const matches: string[] = [];
|
||||||
|
let m;
|
||||||
|
while ((m = pathRegex.exec(iconPath)) !== null) {
|
||||||
|
matches.push(m[1]);
|
||||||
|
}
|
||||||
|
return matches.length > 0 ? matches : [iconPath];
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDomain = (d: string) => (/^[a-zA-Z\s\-_]+$/.test(d) ? d.toUpperCase() : d);
|
||||||
|
|
||||||
|
interface FlatApi {
|
||||||
|
serviceId: number;
|
||||||
|
apiId: number;
|
||||||
|
apiName: string;
|
||||||
|
apiPath: string;
|
||||||
|
apiMethod: string;
|
||||||
|
description: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DomainInfo {
|
||||||
|
domain: string;
|
||||||
|
iconPath: string | null;
|
||||||
|
apis: FlatApi[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const ApiHubDomainPage = () => {
|
||||||
|
const { domainName } = useParams<{ domainName: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [catalog, setCatalog] = useState<ServiceCatalog[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getCatalog()
|
||||||
|
.then((res) => {
|
||||||
|
setCatalog(res.data ?? []);
|
||||||
|
})
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const domainInfo = useMemo<DomainInfo | null>(() => {
|
||||||
|
if (!domainName) return null;
|
||||||
|
const targetKey = decodeURIComponent(domainName).toUpperCase();
|
||||||
|
const apis: FlatApi[] = [];
|
||||||
|
let iconPath: string | null = null;
|
||||||
|
let foundDomain = '';
|
||||||
|
|
||||||
|
for (const svc of catalog) {
|
||||||
|
for (const dg of svc.domains) {
|
||||||
|
if (dg.domain.toUpperCase() === targetKey) {
|
||||||
|
if (!foundDomain) foundDomain = dg.domain;
|
||||||
|
if (iconPath === null && dg.iconPath) iconPath = dg.iconPath;
|
||||||
|
for (const api of dg.apis) {
|
||||||
|
apis.push({
|
||||||
|
serviceId: svc.serviceId,
|
||||||
|
apiId: api.apiId,
|
||||||
|
apiName: api.apiName,
|
||||||
|
apiPath: api.apiPath,
|
||||||
|
apiMethod: api.apiMethod,
|
||||||
|
description: api.description ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!foundDomain) return null;
|
||||||
|
return { domain: foundDomain, iconPath, apis };
|
||||||
|
}, [catalog, domainName]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="text-gray-500 dark:text-gray-400">로딩 중...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!domainInfo) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
<div className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-12 text-center">
|
||||||
|
<p className="text-gray-500 dark:text-gray-400">도메인을 찾을 수 없습니다.</p>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/api-hub')}
|
||||||
|
className="mt-4 text-sm text-indigo-500 hover:underline"
|
||||||
|
>
|
||||||
|
API HUB 홈으로 돌아가기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const palette = getDomainColorByHash(domainInfo.domain);
|
||||||
|
const iconPaths = parseIconPaths(domainInfo.iconPath);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-7xl mx-auto space-y-6">
|
||||||
|
{/* 헤더 카드 */}
|
||||||
|
<div className={`relative overflow-hidden rounded-2xl border bg-white dark:bg-gray-800 ${palette.border} p-6`}>
|
||||||
|
{/* 상단 컬러 라인 */}
|
||||||
|
<div className={`absolute inset-x-0 top-0 h-1 bg-gradient-to-r ${palette.line} to-transparent`} />
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{/* 도메인 아이콘 */}
|
||||||
|
<div className={`flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-2xl ${palette.bg}`}>
|
||||||
|
<svg
|
||||||
|
className={`h-7 w-7 ${palette.color}`}
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
{iconPaths.map((d, i) => (
|
||||||
|
<path key={i} d={d} />
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 도메인명 + API 개수 */}
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight text-gray-900 dark:text-gray-100">
|
||||||
|
{formatDomain(domainInfo.domain)}
|
||||||
|
</h1>
|
||||||
|
<p className={`mt-1 text-sm font-medium ${palette.color}`}>
|
||||||
|
{domainInfo.apis.length}개 API
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* API 목록 */}
|
||||||
|
<div className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 overflow-hidden">
|
||||||
|
{/* 검색 헤더 */}
|
||||||
|
<div className="flex items-center justify-between gap-4 border-b border-gray-200 dark:border-gray-700 px-5 py-3">
|
||||||
|
<h2 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
API 목록
|
||||||
|
<span className="ml-2 text-xs font-normal text-gray-400 dark:text-gray-500">{domainInfo.apis.length}건</span>
|
||||||
|
</h2>
|
||||||
|
<div className="relative">
|
||||||
|
<svg className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-gray-400 pointer-events-none" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder="API 검색..."
|
||||||
|
className="w-52 bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg pl-8 pr-3 py-1.5 text-xs text-gray-900 dark:text-gray-100 placeholder-gray-400 focus:ring-1 focus:ring-indigo-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 리스트 */}
|
||||||
|
{(() => {
|
||||||
|
const filtered = domainInfo.apis.filter((api) => {
|
||||||
|
if (!searchQuery.trim()) return true;
|
||||||
|
const q = searchQuery.toLowerCase();
|
||||||
|
return api.apiName.toLowerCase().includes(q) || (api.description ?? '').toLowerCase().includes(q);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (filtered.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="px-5 py-12 text-center text-sm text-gray-400 dark:text-gray-500">
|
||||||
|
{searchQuery.trim() ? '검색 결과가 없습니다.' : '등록된 API가 없습니다.'}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="divide-y divide-gray-100 dark:divide-gray-700/50">
|
||||||
|
{filtered.map((api) => (
|
||||||
|
<div
|
||||||
|
key={`${api.serviceId}-${api.apiId}`}
|
||||||
|
onClick={() => navigate(`/api-hub/services/${api.serviceId}/apis/${api.apiId}`)}
|
||||||
|
className="flex items-center gap-4 px-5 py-3.5 cursor-pointer transition-colors hover:bg-gray-50 dark:hover:bg-gray-700/50"
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100 truncate">{api.apiName}</p>
|
||||||
|
</div>
|
||||||
|
<svg className="h-4 w-4 flex-shrink-0 text-gray-300 dark:text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ApiHubDomainPage;
|
||||||
223
frontend/src/pages/apihub/ApiHubServicePage.tsx
Normal file
223
frontend/src/pages/apihub/ApiHubServicePage.tsx
Normal file
@ -0,0 +1,223 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import type { ServiceCatalog, ServiceApiItem } from '../../types/apihub';
|
||||||
|
import { getServiceCatalog } from '../../services/apiHubService';
|
||||||
|
|
||||||
|
const METHOD_COLORS: Record<string, string> = {
|
||||||
|
GET: 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300',
|
||||||
|
POST: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300',
|
||||||
|
PUT: 'bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300',
|
||||||
|
PATCH: 'bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300',
|
||||||
|
DELETE: 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300',
|
||||||
|
};
|
||||||
|
|
||||||
|
const HEALTH_DOT: Record<string, string> = {
|
||||||
|
UP: 'bg-green-500',
|
||||||
|
DOWN: 'bg-red-500',
|
||||||
|
UNKNOWN: 'bg-gray-400',
|
||||||
|
};
|
||||||
|
|
||||||
|
const HEALTH_BADGE: Record<string, string> = {
|
||||||
|
UP: 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300',
|
||||||
|
DOWN: 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300',
|
||||||
|
UNKNOWN: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300',
|
||||||
|
};
|
||||||
|
|
||||||
|
const HEALTH_LABEL: Record<string, string> = {
|
||||||
|
UP: '정상',
|
||||||
|
DOWN: '중단',
|
||||||
|
UNKNOWN: '알 수 없음',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface DomainSectionProps {
|
||||||
|
domainName: string;
|
||||||
|
apis: ServiceApiItem[];
|
||||||
|
serviceId: number;
|
||||||
|
onNavigate: (serviceId: number, apiId: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DomainSection = ({ domainName, apis, serviceId, onNavigate }: DomainSectionProps) => (
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<h3 className="text-base font-semibold text-gray-800 dark:text-gray-200">{domainName}</h3>
|
||||||
|
<span className="text-xs px-2 py-0.5 rounded-full bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400">
|
||||||
|
{apis.length}개
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden border border-gray-100 dark:border-gray-700">
|
||||||
|
<table className="w-full text-sm table-fixed">
|
||||||
|
<colgroup>
|
||||||
|
<col className="w-[8%]" />
|
||||||
|
<col className="w-[27%]" />
|
||||||
|
<col className="w-[20%]" />
|
||||||
|
<col className="w-[40%]" />
|
||||||
|
<col className="w-[5%]" />
|
||||||
|
</colgroup>
|
||||||
|
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">메서드</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">경로</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">API명</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">설명</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">상태</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{apis.map((api) => (
|
||||||
|
<tr
|
||||||
|
key={api.apiId}
|
||||||
|
className="hover:bg-blue-50 dark:hover:bg-gray-700 cursor-pointer transition-colors"
|
||||||
|
onClick={() => onNavigate(serviceId, api.apiId)}
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span
|
||||||
|
className={`inline-block px-2 py-0.5 rounded text-xs font-bold uppercase ${METHOD_COLORS[api.apiMethod] ?? 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300'}`}
|
||||||
|
>
|
||||||
|
{api.apiMethod}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-xs text-gray-700 dark:text-gray-300 truncate" title={api.apiPath}>
|
||||||
|
{api.apiPath}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-gray-900 dark:text-gray-100 font-medium truncate" title={api.apiName}>
|
||||||
|
{api.apiName}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-gray-500 dark:text-gray-400 truncate" title={api.description || ''}>
|
||||||
|
{api.description || <span className="text-gray-300 dark:text-gray-600">-</span>}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{api.isActive ? (
|
||||||
|
<span className="inline-block w-2 h-2 rounded-full bg-green-500" title="활성" />
|
||||||
|
) : (
|
||||||
|
<span className="inline-block w-2 h-2 rounded-full bg-gray-300 dark:bg-gray-600" title="비활성" />
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ApiHubServicePage = () => {
|
||||||
|
const { serviceId } = useParams<{ serviceId: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [service, setService] = useState<ServiceCatalog | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
if (!serviceId) return;
|
||||||
|
try {
|
||||||
|
const res = await getServiceCatalog(Number(serviceId));
|
||||||
|
if (res.success && res.data) {
|
||||||
|
setService(res.data);
|
||||||
|
} else {
|
||||||
|
setError('서비스 정보를 불러오지 못했습니다');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError('서비스 정보를 불러오는 중 오류가 발생했습니다');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [serviceId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
}, [fetchData]);
|
||||||
|
|
||||||
|
const handleApiNavigate = (svcId: number, apiId: number) => {
|
||||||
|
navigate(`/api-hub/services/${svcId}/apis/${apiId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="text-gray-500 dark:text-gray-400">로딩 중...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !service) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/api-hub')}
|
||||||
|
className="text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 mb-4 inline-block"
|
||||||
|
>
|
||||||
|
← API HUB으로
|
||||||
|
</button>
|
||||||
|
<div className="text-center py-20 text-gray-500 dark:text-gray-400">
|
||||||
|
{error ?? '서비스를 찾을 수 없습니다'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const domainsMap = new Map<string, ServiceApiItem[]>();
|
||||||
|
for (const dg of service.domains) {
|
||||||
|
const key = dg.domain ? dg.domain.toUpperCase() : '기타';
|
||||||
|
domainsMap.set(key, dg.apis);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If any apis fall under null domain and weren't covered by domains array, show them under 기타
|
||||||
|
const domainEntries = [...domainsMap.entries()];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/api-hub')}
|
||||||
|
className="text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 mb-4 inline-block"
|
||||||
|
>
|
||||||
|
← API HUB으로
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Service Header */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 mb-6 border border-gray-100 dark:border-gray-700">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-3 mb-1">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">{service.serviceName}</h1>
|
||||||
|
<span className="text-sm text-gray-500 dark:text-gray-400 font-mono">{service.serviceCode}</span>
|
||||||
|
</div>
|
||||||
|
{service.description && (
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mt-2">{service.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-end gap-2 ml-6 shrink-0">
|
||||||
|
<span
|
||||||
|
className={`flex items-center gap-1.5 px-3 py-1 rounded-full text-sm font-medium ${HEALTH_BADGE[service.healthStatus] ?? 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300'}`}
|
||||||
|
>
|
||||||
|
<span className={`w-2 h-2 rounded-full ${HEALTH_DOT[service.healthStatus] ?? 'bg-gray-400'}`} />
|
||||||
|
{HEALTH_LABEL[service.healthStatus] ?? service.healthStatus}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-4 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
<span>API {service.apiCount}개</span>
|
||||||
|
<span>도메인 {service.domains.length}개</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* API 목록 by Domain */}
|
||||||
|
{domainEntries.length > 0 ? (
|
||||||
|
domainEntries.map(([domain, apis]) => (
|
||||||
|
<DomainSection
|
||||||
|
key={domain}
|
||||||
|
domainName={domain}
|
||||||
|
apis={apis}
|
||||||
|
serviceId={service.serviceId}
|
||||||
|
onNavigate={handleApiNavigate}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-8 text-center text-gray-400 dark:text-gray-500">
|
||||||
|
등록된 API가 없습니다
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ApiHubServicePage;
|
||||||
@ -75,6 +75,7 @@ const KeyAdminPage = () => {
|
|||||||
const [reviewComment, setReviewComment] = useState('');
|
const [reviewComment, setReviewComment] = useState('');
|
||||||
const [adjustedFromDate, setAdjustedFromDate] = useState('');
|
const [adjustedFromDate, setAdjustedFromDate] = useState('');
|
||||||
const [adjustedToDate, setAdjustedToDate] = useState('');
|
const [adjustedToDate, setAdjustedToDate] = useState('');
|
||||||
|
const [adjustedDailyLimit, setAdjustedDailyLimit] = useState('');
|
||||||
const [activeReviewTab, setActiveReviewTab] = useState<'info' | 'apis'>('info');
|
const [activeReviewTab, setActiveReviewTab] = useState<'info' | 'apis'>('info');
|
||||||
const [showRejectConfirm, setShowRejectConfirm] = useState(false);
|
const [showRejectConfirm, setShowRejectConfirm] = useState(false);
|
||||||
const [showApproveConfirm, setShowApproveConfirm] = useState(false);
|
const [showApproveConfirm, setShowApproveConfirm] = useState(false);
|
||||||
@ -169,6 +170,7 @@ const KeyAdminPage = () => {
|
|||||||
setReviewComment(req.reviewComment || '');
|
setReviewComment(req.reviewComment || '');
|
||||||
setAdjustedFromDate(req.usageFromDate ? req.usageFromDate.split('T')[0] : '');
|
setAdjustedFromDate(req.usageFromDate ? req.usageFromDate.split('T')[0] : '');
|
||||||
setAdjustedToDate(req.usageToDate ? req.usageToDate.split('T')[0] : '');
|
setAdjustedToDate(req.usageToDate ? req.usageToDate.split('T')[0] : '');
|
||||||
|
setAdjustedDailyLimit(req.dailyRequestEstimate != null ? String(req.dailyRequestEstimate) : '');
|
||||||
setActiveReviewTab('info');
|
setActiveReviewTab('info');
|
||||||
setShowRejectConfirm(false);
|
setShowRejectConfirm(false);
|
||||||
setShowApproveConfirm(false);
|
setShowApproveConfirm(false);
|
||||||
@ -185,6 +187,7 @@ const KeyAdminPage = () => {
|
|||||||
setReviewComment('');
|
setReviewComment('');
|
||||||
setAdjustedFromDate(req.usageFromDate ? req.usageFromDate.split('T')[0] : '');
|
setAdjustedFromDate(req.usageFromDate ? req.usageFromDate.split('T')[0] : '');
|
||||||
setAdjustedToDate(req.usageToDate ? req.usageToDate.split('T')[0] : '');
|
setAdjustedToDate(req.usageToDate ? req.usageToDate.split('T')[0] : '');
|
||||||
|
setAdjustedDailyLimit(req.dailyRequestEstimate != null ? String(req.dailyRequestEstimate) : '');
|
||||||
setActiveReviewTab('info');
|
setActiveReviewTab('info');
|
||||||
setShowRejectConfirm(false);
|
setShowRejectConfirm(false);
|
||||||
setShowApproveConfirm(false);
|
setShowApproveConfirm(false);
|
||||||
@ -217,6 +220,7 @@ const KeyAdminPage = () => {
|
|||||||
adjustedApiIds: status === 'APPROVED' ? Array.from(adjustedApiIds) : undefined,
|
adjustedApiIds: status === 'APPROVED' ? Array.from(adjustedApiIds) : undefined,
|
||||||
adjustedFromDate: status === 'APPROVED' && adjustedFromDate ? adjustedFromDate : undefined,
|
adjustedFromDate: status === 'APPROVED' && adjustedFromDate ? adjustedFromDate : undefined,
|
||||||
adjustedToDate: status === 'APPROVED' && adjustedToDate ? adjustedToDate : undefined,
|
adjustedToDate: status === 'APPROVED' && adjustedToDate ? adjustedToDate : undefined,
|
||||||
|
adjustedDailyRequestLimit: status === 'APPROVED' && adjustedDailyLimit ? Number(adjustedDailyLimit) : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
@ -896,11 +900,29 @@ const KeyAdminPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-gray-500 dark:text-gray-500">예상 요청량</span>
|
<span className="text-xs text-gray-500 dark:text-gray-500">예상 요청량</span>
|
||||||
</div>
|
</div>
|
||||||
|
{isReviewReadOnly ? (
|
||||||
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100 pl-10">
|
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100 pl-10">
|
||||||
{selectedRequest.dailyRequestEstimate != null
|
{selectedRequest.dailyRequestEstimate != null
|
||||||
? `${Number(selectedRequest.dailyRequestEstimate).toLocaleString()}건/일`
|
? `${Number(selectedRequest.dailyRequestEstimate).toLocaleString()}건/일`
|
||||||
: '-'}
|
: '-'}
|
||||||
</p>
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="pl-10">
|
||||||
|
<select
|
||||||
|
value={adjustedDailyLimit}
|
||||||
|
onChange={(e) => setAdjustedDailyLimit(e.target.value)}
|
||||||
|
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-2 py-1 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="">선택하세요</option>
|
||||||
|
<option value="100">100 이하</option>
|
||||||
|
<option value="500">100~500</option>
|
||||||
|
<option value="1000">500~1,000</option>
|
||||||
|
<option value="5000">1,000~5,000</option>
|
||||||
|
<option value="10000">5,000~10,000</option>
|
||||||
|
<option value="50000">10,000 이상</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-500 pl-10 mt-0.5">{totalApiCount}개 API</p>
|
<p className="text-xs text-gray-500 dark:text-gray-500 pl-10 mt-0.5">{totalApiCount}개 API</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,16 +1,8 @@
|
|||||||
import { useState, useEffect, useMemo, useRef } from 'react';
|
import { useState, useEffect, useMemo, useRef } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import type { ServiceInfo, ServiceApi } from '../../types/service';
|
import { getCatalog } from '../../services/apiHubService';
|
||||||
import { getServices, getServiceApis } from '../../services/serviceService';
|
|
||||||
import { createKeyRequest } from '../../services/apiKeyService';
|
import { createKeyRequest } from '../../services/apiKeyService';
|
||||||
|
import type { ServiceCatalog } from '../../types/apihub';
|
||||||
const METHOD_BADGE_STYLE: Record<string, string> = {
|
|
||||||
GET: 'bg-emerald-900/80 text-emerald-300 border border-emerald-700/50',
|
|
||||||
POST: 'bg-amber-900/80 text-amber-300 border border-amber-700/50',
|
|
||||||
PUT: 'bg-blue-900/80 text-blue-300 border border-blue-700/50',
|
|
||||||
DELETE: 'bg-red-900/80 text-red-300 border border-red-700/50',
|
|
||||||
PATCH: 'bg-purple-900/80 text-purple-300 border border-purple-700/50',
|
|
||||||
};
|
|
||||||
|
|
||||||
const IndeterminateCheckbox = ({ checked, indeterminate, onChange, className }: { checked: boolean; indeterminate: boolean; onChange: () => void; className?: string }) => {
|
const IndeterminateCheckbox = ({ checked, indeterminate, onChange, className }: { checked: boolean; indeterminate: boolean; onChange: () => void; className?: string }) => {
|
||||||
const ref = useRef<HTMLInputElement>(null);
|
const ref = useRef<HTMLInputElement>(null);
|
||||||
@ -20,36 +12,23 @@ const IndeterminateCheckbox = ({ checked, indeterminate, onChange, className }:
|
|||||||
return <input ref={ref} type="checkbox" checked={checked} onChange={onChange} className={className || 'rounded'} />;
|
return <input ref={ref} type="checkbox" checked={checked} onChange={onChange} className={className || 'rounded'} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface DomainGroup {
|
interface FlatApi {
|
||||||
domain: string;
|
apiId: number;
|
||||||
apis: ServiceApi[];
|
apiName: string;
|
||||||
|
description: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const groupApisByDomain = (apis: ServiceApi[]): DomainGroup[] => {
|
interface FlatDomainGroup {
|
||||||
const domainMap = new Map<string, ServiceApi[]>();
|
domain: string;
|
||||||
|
apis: FlatApi[];
|
||||||
apis.forEach((api) => {
|
}
|
||||||
const domain = api.apiDomain || '미분류';
|
|
||||||
if (!domainMap.has(domain)) {
|
|
||||||
domainMap.set(domain, []);
|
|
||||||
}
|
|
||||||
domainMap.get(domain)!.push(api);
|
|
||||||
});
|
|
||||||
|
|
||||||
const result: DomainGroup[] = [];
|
|
||||||
domainMap.forEach((domainApis, domain) => {
|
|
||||||
result.push({ domain, apis: domainApis });
|
|
||||||
});
|
|
||||||
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
const KeyRequestPage = () => {
|
const KeyRequestPage = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const preApiId = searchParams.get('apiId');
|
||||||
|
|
||||||
const [services, setServices] = useState<ServiceInfo[]>([]);
|
const [catalog, setCatalog] = useState<ServiceCatalog[]>([]);
|
||||||
const [serviceApisMap, setServiceApisMap] = useState<Record<number, ServiceApi[]>>({});
|
|
||||||
const [expandedServices, setExpandedServices] = useState<Set<number>>(new Set());
|
|
||||||
const [expandedDomains, setExpandedDomains] = useState<Set<string>>(new Set());
|
const [expandedDomains, setExpandedDomains] = useState<Set<string>>(new Set());
|
||||||
const [selectedApiIds, setSelectedApiIds] = useState<Set<number>>(new Set());
|
const [selectedApiIds, setSelectedApiIds] = useState<Set<number>>(new Set());
|
||||||
const [keyName, setKeyName] = useState('');
|
const [keyName, setKeyName] = useState('');
|
||||||
@ -71,61 +50,95 @@ const KeyRequestPage = () => {
|
|||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const servicesRes = await getServices();
|
const catalogRes = await getCatalog();
|
||||||
if (servicesRes.success && servicesRes.data) {
|
if (catalogRes.success && catalogRes.data) {
|
||||||
const activeServices = servicesRes.data.filter((s) => s.isActive);
|
setCatalog(catalogRes.data);
|
||||||
setServices(activeServices);
|
|
||||||
|
|
||||||
const apisMap: Record<number, ServiceApi[]> = {};
|
// 쿼리 파라미터로 전달된 API 자동 선택
|
||||||
await Promise.all(
|
if (preApiId) {
|
||||||
activeServices.map(async (service) => {
|
const aId = Number(preApiId);
|
||||||
const apisRes = await getServiceApis(service.serviceId);
|
for (const service of catalogRes.data) {
|
||||||
if (apisRes.success && apisRes.data) {
|
for (const domainGroup of service.domains) {
|
||||||
apisMap[service.serviceId] = apisRes.data.filter((a) => a.isActive);
|
const targetApi = domainGroup.apis.find((a) => a.apiId === aId);
|
||||||
|
if (targetApi) {
|
||||||
|
setSelectedApiIds(new Set([aId]));
|
||||||
|
setExpandedDomains(new Set([domainGroup.domain]));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}),
|
|
||||||
);
|
|
||||||
setServiceApisMap(apisMap);
|
|
||||||
} else {
|
} else {
|
||||||
setError(servicesRes.message || '서비스 목록을 불러오는데 실패했습니다.');
|
setError(catalogRes.message || '카탈로그를 불러오는데 실패했습니다.');
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setError('서비스 목록을 불러오는데 실패했습니다.');
|
setError('카탈로그를 불러오는데 실패했습니다.');
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchData();
|
fetchData();
|
||||||
}, []);
|
}, [preApiId]);
|
||||||
|
|
||||||
const groupedApisMap = useMemo(() => {
|
// catalog → FlatDomainGroup[] 변환 (도메인 기준 플랫 그룹핑, 알파벳순 정렬)
|
||||||
const result: Record<number, DomainGroup[]> = {};
|
const flatDomainGroups = useMemo<FlatDomainGroup[]>(() => {
|
||||||
Object.entries(serviceApisMap).forEach(([serviceId, apis]) => {
|
const domainMap = new Map<string, FlatApi[]>();
|
||||||
result[Number(serviceId)] = groupApisByDomain(apis);
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
}, [serviceApisMap]);
|
|
||||||
|
|
||||||
const handleToggleService = (serviceId: number) => {
|
for (const service of catalog) {
|
||||||
setExpandedServices((prev) => {
|
for (const domainGroup of service.domains) {
|
||||||
const next = new Set(prev);
|
const domainName = domainGroup.domain || '미분류';
|
||||||
if (next.has(serviceId)) {
|
if (!domainMap.has(domainName)) {
|
||||||
next.delete(serviceId);
|
domainMap.set(domainName, []);
|
||||||
} else {
|
|
||||||
next.add(serviceId);
|
|
||||||
}
|
}
|
||||||
return next;
|
const existing = domainMap.get(domainName)!;
|
||||||
|
for (const api of domainGroup.apis) {
|
||||||
|
existing.push({
|
||||||
|
apiId: api.apiId,
|
||||||
|
apiName: api.apiName,
|
||||||
|
description: api.description,
|
||||||
});
|
});
|
||||||
};
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleToggleDomain = (key: string) => {
|
const result: FlatDomainGroup[] = [];
|
||||||
|
domainMap.forEach((apis, domain) => {
|
||||||
|
result.push({ domain, apis });
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, [catalog]);
|
||||||
|
|
||||||
|
const allApis = useMemo<FlatApi[]>(() => {
|
||||||
|
return flatDomainGroups.flatMap((dg) => dg.apis);
|
||||||
|
}, [flatDomainGroups]);
|
||||||
|
|
||||||
|
const allApisSelected = allApis.length > 0 && allApis.every((a) => selectedApiIds.has(a.apiId));
|
||||||
|
const someApisSelected = allApis.some((a) => selectedApiIds.has(a.apiId));
|
||||||
|
|
||||||
|
const filteredDomainGroups = useMemo<FlatDomainGroup[]>(() => {
|
||||||
|
if (!searchQuery.trim()) return flatDomainGroups;
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
return flatDomainGroups
|
||||||
|
.map((dg) => ({
|
||||||
|
domain: dg.domain,
|
||||||
|
apis: dg.apis.filter(
|
||||||
|
(a) =>
|
||||||
|
a.apiName.toLowerCase().includes(query) ||
|
||||||
|
(a.description?.toLowerCase().includes(query) ?? false),
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
.filter((dg) => dg.apis.length > 0);
|
||||||
|
}, [flatDomainGroups, searchQuery]);
|
||||||
|
|
||||||
|
const handleToggleDomain = (domain: string) => {
|
||||||
setExpandedDomains((prev) => {
|
setExpandedDomains((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
if (next.has(key)) {
|
if (next.has(domain)) {
|
||||||
next.delete(key);
|
next.delete(domain);
|
||||||
} else {
|
} else {
|
||||||
next.add(key);
|
next.add(domain);
|
||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
@ -143,26 +156,8 @@ const KeyRequestPage = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleToggleAllServiceApis = (serviceId: number) => {
|
const handleToggleAllDomainApis = (domain: string) => {
|
||||||
const apis = serviceApisMap[serviceId] || [];
|
const domainGroup = flatDomainGroups.find((dg) => dg.domain === domain);
|
||||||
const allSelected = apis.every((a) => selectedApiIds.has(a.apiId));
|
|
||||||
|
|
||||||
setSelectedApiIds((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
apis.forEach((a) => {
|
|
||||||
if (allSelected) {
|
|
||||||
next.delete(a.apiId);
|
|
||||||
} else {
|
|
||||||
next.add(a.apiId);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleToggleAllDomainApis = (serviceId: number, domain: string) => {
|
|
||||||
const domainGroups = groupedApisMap[serviceId] || [];
|
|
||||||
const domainGroup = domainGroups.find((d) => d.domain === domain);
|
|
||||||
if (!domainGroup) return;
|
if (!domainGroup) return;
|
||||||
|
|
||||||
const domainApis = domainGroup.apis;
|
const domainApis = domainGroup.apis;
|
||||||
@ -181,15 +176,6 @@ const KeyRequestPage = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const allApis = useMemo(() => {
|
|
||||||
return Object.values(serviceApisMap).flat();
|
|
||||||
}, [serviceApisMap]);
|
|
||||||
|
|
||||||
const allApisSelected = allApis.length > 0 && allApis.every((a) => selectedApiIds.has(a.apiId));
|
|
||||||
const someApisSelected = allApis.some((a) => selectedApiIds.has(a.apiId));
|
|
||||||
|
|
||||||
const handleToggleAll = () => {
|
const handleToggleAll = () => {
|
||||||
if (allApisSelected) {
|
if (allApisSelected) {
|
||||||
setSelectedApiIds(new Set());
|
setSelectedApiIds(new Set());
|
||||||
@ -202,28 +188,6 @@ const KeyRequestPage = () => {
|
|||||||
setSelectedApiIds(new Set());
|
setSelectedApiIds(new Set());
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredGroupedApisMap = useMemo(() => {
|
|
||||||
if (!searchQuery.trim()) return groupedApisMap;
|
|
||||||
const query = searchQuery.toLowerCase();
|
|
||||||
const result: Record<number, DomainGroup[]> = {};
|
|
||||||
Object.entries(groupedApisMap).forEach(([serviceId, domainGroups]) => {
|
|
||||||
const filtered = domainGroups
|
|
||||||
.map((dg) => ({
|
|
||||||
domain: dg.domain,
|
|
||||||
apis: dg.apis.filter(
|
|
||||||
(a) =>
|
|
||||||
a.apiName.toLowerCase().includes(query) ||
|
|
||||||
a.apiPath.toLowerCase().includes(query),
|
|
||||||
),
|
|
||||||
}))
|
|
||||||
.filter((dg) => dg.apis.length > 0);
|
|
||||||
if (filtered.length > 0) {
|
|
||||||
result[Number(serviceId)] = filtered;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
}, [groupedApisMap, searchQuery]);
|
|
||||||
|
|
||||||
const handlePresetPeriod = (months: number) => {
|
const handlePresetPeriod = (months: number) => {
|
||||||
const from = new Date();
|
const from = new Date();
|
||||||
const to = new Date();
|
const to = new Date();
|
||||||
@ -480,42 +444,39 @@ const KeyRequestPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Service cards */}
|
{/* Domain cards */}
|
||||||
<div className="p-4 space-y-3">
|
<div className="p-4 space-y-3">
|
||||||
{services.map((service) => {
|
{filteredDomainGroups.map((domainGroup) => {
|
||||||
const apis = serviceApisMap[service.serviceId] || [];
|
const isDomainExpanded = expandedDomains.has(domainGroup.domain);
|
||||||
const domainGroups = filteredGroupedApisMap[service.serviceId] || [];
|
const domainApis = domainGroup.apis;
|
||||||
const isServiceExpanded = expandedServices.has(service.serviceId);
|
const allDomainSelected = domainApis.length > 0 && domainApis.every((a) => selectedApiIds.has(a.apiId));
|
||||||
const selectedCount = apis.filter((a) => selectedApiIds.has(a.apiId)).length;
|
const someDomainSelected = !allDomainSelected && domainApis.some((a) => selectedApiIds.has(a.apiId));
|
||||||
const allServiceSelected = apis.length > 0 && apis.every((a) => selectedApiIds.has(a.apiId));
|
const selectedCount = domainApis.filter((a) => selectedApiIds.has(a.apiId)).length;
|
||||||
const someServiceSelected = !allServiceSelected && apis.some((a) => selectedApiIds.has(a.apiId));
|
|
||||||
const hasSelections = selectedCount > 0;
|
const hasSelections = selectedCount > 0;
|
||||||
|
|
||||||
if (searchQuery.trim() && domainGroups.length === 0) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={service.serviceId}
|
key={domainGroup.domain}
|
||||||
className={`rounded-xl border overflow-hidden transition-colors ${hasSelections ? 'border-blue-300 dark:border-blue-700' : 'border-gray-200 dark:border-gray-700'}`}
|
className={`rounded-xl border overflow-hidden transition-colors ${hasSelections ? 'border-blue-300 dark:border-blue-700' : 'border-gray-200 dark:border-gray-700'}`}
|
||||||
>
|
>
|
||||||
{/* Service header */}
|
{/* Domain header */}
|
||||||
<div
|
<div
|
||||||
className={`flex items-center justify-between px-5 py-3.5 cursor-pointer ${hasSelections ? 'bg-blue-50/50 dark:bg-blue-900/20' : 'bg-gray-50 dark:bg-gray-800/80'}`}
|
className={`flex items-center justify-between px-5 py-3.5 cursor-pointer ${hasSelections ? 'bg-blue-50/50 dark:bg-blue-900/20' : 'bg-gray-50 dark:bg-gray-800/80'}`}
|
||||||
onClick={() => handleToggleService(service.serviceId)}
|
onClick={() => handleToggleDomain(domainGroup.domain)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<svg className={`h-4 w-4 text-gray-400 transition-transform ${isServiceExpanded ? 'rotate-90' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg className={`h-4 w-4 text-gray-400 transition-transform ${isDomainExpanded ? 'rotate-90' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
</svg>
|
</svg>
|
||||||
<label className="flex items-center cursor-pointer" onClick={(e) => e.stopPropagation()}>
|
<label className="flex items-center cursor-pointer" onClick={(e) => e.stopPropagation()}>
|
||||||
<IndeterminateCheckbox
|
<IndeterminateCheckbox
|
||||||
checked={allServiceSelected}
|
checked={allDomainSelected}
|
||||||
indeterminate={someServiceSelected}
|
indeterminate={someDomainSelected}
|
||||||
onChange={() => handleToggleAllServiceApis(service.serviceId)}
|
onChange={() => handleToggleAllDomainApis(domainGroup.domain)}
|
||||||
className="rounded"
|
className="rounded"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<span className="font-semibold text-gray-900 dark:text-gray-100">{service.serviceName}</span>
|
<span className="font-semibold text-gray-900 dark:text-gray-100">{/^[a-zA-Z\s\-_]+$/.test(domainGroup.domain) ? domainGroup.domain.toUpperCase() : domainGroup.domain}</span>
|
||||||
{selectedCount > 0 && (
|
{selectedCount > 0 && (
|
||||||
<span className="text-xs font-medium bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 px-2 py-0.5 rounded-full">
|
<span className="text-xs font-medium bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 px-2 py-0.5 rounded-full">
|
||||||
{selectedCount} selected
|
{selectedCount} selected
|
||||||
@ -523,64 +484,22 @@ const KeyRequestPage = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 px-2 py-0.5 rounded-full">
|
<span className="text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 px-2 py-0.5 rounded-full">
|
||||||
{apis.length}개 API
|
{domainApis.length}개 API
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Service body */}
|
{/* API list */}
|
||||||
{isServiceExpanded && (
|
|
||||||
<div className="px-4 py-3 space-y-2 bg-white dark:bg-gray-900">
|
|
||||||
{domainGroups.map((domainGroup) => {
|
|
||||||
const domainKey = `${service.serviceId}-${domainGroup.domain}`;
|
|
||||||
const isDomainExpanded = expandedDomains.has(domainKey);
|
|
||||||
const domainApis = domainGroup.apis;
|
|
||||||
const allDomainSelected = domainApis.length > 0 && domainApis.every((a) => selectedApiIds.has(a.apiId));
|
|
||||||
const someDomainSelected = !allDomainSelected && domainApis.some((a) => selectedApiIds.has(a.apiId));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={domainKey}>
|
|
||||||
{/* Domain row */}
|
|
||||||
<div
|
|
||||||
className="ml-5 flex items-center gap-2.5 px-3 py-2 cursor-pointer rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50"
|
|
||||||
onClick={() => handleToggleDomain(domainKey)}
|
|
||||||
>
|
|
||||||
<svg className={`h-3.5 w-3.5 text-gray-400 transition-transform ${isDomainExpanded ? 'rotate-90' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
|
||||||
</svg>
|
|
||||||
<label className="flex items-center cursor-pointer" onClick={(e) => e.stopPropagation()}>
|
|
||||||
<IndeterminateCheckbox
|
|
||||||
checked={allDomainSelected}
|
|
||||||
indeterminate={someDomainSelected}
|
|
||||||
onChange={() => handleToggleAllDomainApis(service.serviceId, domainGroup.domain)}
|
|
||||||
className="rounded"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<span className="text-sm font-semibold text-gray-800 dark:text-gray-200">{domainGroup.domain}</span>
|
|
||||||
<span className="text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 px-2 py-0.5 rounded-full">
|
|
||||||
{domainApis.length}개
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* API table */}
|
|
||||||
{isDomainExpanded && (
|
{isDomainExpanded && (
|
||||||
<div className="ml-3 mr-3 mb-3 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
|
<div className="divide-y divide-gray-100 dark:divide-gray-700/50 bg-white dark:bg-gray-900">
|
||||||
{/* Table header */}
|
|
||||||
<div className="grid grid-cols-[24px_56px_1fr_1fr] gap-3 items-center bg-gray-50 dark:bg-gray-800 px-4 py-1.5 text-xs font-semibold text-gray-500 dark:text-gray-500 uppercase tracking-wider">
|
|
||||||
<div></div>
|
|
||||||
<div>Method</div>
|
|
||||||
<div>Path</div>
|
|
||||||
<div>Name</div>
|
|
||||||
</div>
|
|
||||||
{/* Table rows */}
|
|
||||||
{domainApis.map((api) => {
|
{domainApis.map((api) => {
|
||||||
const isSelected = selectedApiIds.has(api.apiId);
|
const isSelected = selectedApiIds.has(api.apiId);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={api.apiId}
|
key={api.apiId}
|
||||||
className={`grid grid-cols-[24px_56px_1fr_1fr] gap-3 items-center px-4 py-2.5 hover:bg-gray-50 dark:hover:bg-gray-700/30 border-b border-gray-100 dark:border-gray-700/50 last:border-b-0 cursor-pointer ${isSelected ? 'bg-blue-50/50 dark:bg-blue-900/10' : ''}`}
|
className={`flex items-start gap-3 px-5 py-3 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700/30 ${isSelected ? 'bg-blue-50/50 dark:bg-blue-900/10' : ''}`}
|
||||||
onClick={() => handleToggleApi(api.apiId)}
|
onClick={() => handleToggleApi(api.apiId)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-center">
|
<div className="flex items-center pt-0.5">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={isSelected}
|
checked={isSelected}
|
||||||
@ -589,13 +508,9 @@ const KeyRequestPage = () => {
|
|||||||
className="rounded"
|
className="rounded"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="flex-1 min-w-0">
|
||||||
<span className={`inline-block px-2 py-0.5 rounded text-xs font-bold ${METHOD_BADGE_STYLE[api.apiMethod] || 'bg-gray-700 text-gray-300 border border-gray-600'}`}>
|
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100 truncate">{api.apiName}</p>
|
||||||
{api.apiMethod}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="font-mono text-xs text-gray-700 dark:text-gray-300 truncate">{api.apiPath}</div>
|
|
||||||
<div className="text-sm text-gray-600 dark:text-gray-400 truncate">{api.apiName}</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@ -604,17 +519,9 @@ const KeyRequestPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{apis.length === 0 && (
|
{filteredDomainGroups.length === 0 && (
|
||||||
<p className="text-sm text-gray-400 dark:text-gray-500 py-3 ml-5">등록된 API가 없습니다.</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{services.length === 0 && (
|
|
||||||
<div className="px-6 py-8 text-center text-gray-400 dark:text-gray-500">
|
<div className="px-6 py-8 text-center text-gray-400 dark:text-gray-500">
|
||||||
등록된 서비스가 없습니다.
|
{searchQuery.trim() ? '검색 결과가 없습니다.' : '등록된 API가 없습니다.'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
11
frontend/src/services/apiHubService.ts
Normal file
11
frontend/src/services/apiHubService.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { get } from './apiClient';
|
||||||
|
import type { ServiceCatalog, RecentApi, PopularApi } from '../types/apihub';
|
||||||
|
import type { ApiDetailInfo } from '../types/service';
|
||||||
|
|
||||||
|
export const getCatalog = () => get<ServiceCatalog[]>('/api-hub/catalog');
|
||||||
|
export const getRecentApis = () => get<RecentApi[]>('/api-hub/recent-apis');
|
||||||
|
export const getPopularApis = () => get<PopularApi[]>('/api-hub/popular-apis');
|
||||||
|
export const getServiceCatalog = (serviceId: number) =>
|
||||||
|
get<ServiceCatalog>(`/api-hub/services/${serviceId}`);
|
||||||
|
export const getApiHubApiDetail = (serviceId: number, apiId: number) =>
|
||||||
|
get<ApiDetailInfo>(`/api-hub/services/${serviceId}/apis/${apiId}`);
|
||||||
12
frontend/src/services/configService.ts
Normal file
12
frontend/src/services/configService.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { get, put } from './apiClient';
|
||||||
|
import type { SystemConfigInfo } from '../types/service';
|
||||||
|
import type { ApiResponse } from '../types/api';
|
||||||
|
|
||||||
|
export const getSystemConfig = (configKey: string): Promise<ApiResponse<SystemConfigInfo>> =>
|
||||||
|
get<SystemConfigInfo>(`/config/${configKey}`);
|
||||||
|
|
||||||
|
export const updateSystemConfig = (
|
||||||
|
configKey: string,
|
||||||
|
configValue: string,
|
||||||
|
): Promise<ApiResponse<SystemConfigInfo>> =>
|
||||||
|
put<SystemConfigInfo>(`/config/${configKey}`, { configValue });
|
||||||
@ -1,15 +1,50 @@
|
|||||||
import { get, post, put } from './apiClient';
|
import { get, post, put, del } from './apiClient';
|
||||||
import type {
|
import type {
|
||||||
ServiceInfo,
|
ServiceInfo,
|
||||||
ServiceApi,
|
ServiceApi,
|
||||||
CreateServiceRequest,
|
CreateServiceRequest,
|
||||||
UpdateServiceRequest,
|
UpdateServiceRequest,
|
||||||
CreateServiceApiRequest,
|
CreateServiceApiRequest,
|
||||||
|
UpdateServiceApiRequest,
|
||||||
|
ApiDetailInfo,
|
||||||
|
SaveApiSpecRequest,
|
||||||
|
SaveApiParamRequest,
|
||||||
|
ApiSpecInfo,
|
||||||
|
ApiParamInfo,
|
||||||
} from '../types/service';
|
} from '../types/service';
|
||||||
|
import type { ApiDomainInfo } from '../types/apihub';
|
||||||
|
|
||||||
|
export interface CreateDomainRequest {
|
||||||
|
domainName: string;
|
||||||
|
iconPath?: string | null;
|
||||||
|
sortOrder?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateDomainRequest {
|
||||||
|
domainName: string;
|
||||||
|
iconPath?: string | null;
|
||||||
|
sortOrder?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export const getServices = () => get<ServiceInfo[]>('/services');
|
export const getServices = () => get<ServiceInfo[]>('/services');
|
||||||
export const createService = (req: CreateServiceRequest) => post<ServiceInfo>('/services', req);
|
export const createService = (req: CreateServiceRequest) => post<ServiceInfo>('/services', req);
|
||||||
export const updateService = (id: number, req: UpdateServiceRequest) => put<ServiceInfo>(`/services/${id}`, req);
|
export const updateService = (id: number, req: UpdateServiceRequest) => put<ServiceInfo>(`/services/${id}`, req);
|
||||||
|
export const deleteService = (id: number) => del<void>(`/services/${id}`);
|
||||||
export const getServiceApis = (serviceId: number) => get<ServiceApi[]>(`/services/${serviceId}/apis`);
|
export const getServiceApis = (serviceId: number) => get<ServiceApi[]>(`/services/${serviceId}/apis`);
|
||||||
export const createServiceApi = (serviceId: number, req: CreateServiceApiRequest) =>
|
export const createServiceApi = (serviceId: number, req: CreateServiceApiRequest) =>
|
||||||
post<ServiceApi>(`/services/${serviceId}/apis`, req);
|
post<ServiceApi>(`/services/${serviceId}/apis`, req);
|
||||||
|
export const updateServiceApi = (serviceId: number, apiId: number, req: UpdateServiceApiRequest) =>
|
||||||
|
put<ServiceApi>(`/services/${serviceId}/apis/${apiId}`, req);
|
||||||
|
export const deleteServiceApi = (serviceId: number, apiId: number) =>
|
||||||
|
del<void>(`/services/${serviceId}/apis/${apiId}`);
|
||||||
|
export const getApiDetail = (serviceId: number, apiId: number) =>
|
||||||
|
get<ApiDetailInfo>(`/services/${serviceId}/apis/${apiId}/spec`);
|
||||||
|
export const saveApiSpec = (serviceId: number, apiId: number, req: SaveApiSpecRequest) =>
|
||||||
|
put<ApiSpecInfo>(`/services/${serviceId}/apis/${apiId}/spec`, req);
|
||||||
|
export const saveApiParams = (serviceId: number, apiId: number, params: SaveApiParamRequest[]) =>
|
||||||
|
put<ApiParamInfo[]>(`/services/${serviceId}/apis/${apiId}/params`, params);
|
||||||
|
|
||||||
|
export const getDomains = () => get<ApiDomainInfo[]>('/domains');
|
||||||
|
export const createDomain = (req: CreateDomainRequest) => post<ApiDomainInfo>('/domains', req);
|
||||||
|
export const updateDomain = (id: number, req: UpdateDomainRequest) => put<ApiDomainInfo>(`/domains/${id}`, req);
|
||||||
|
export const deleteDomain = (id: number) => del<void>(`/domains/${id}`);
|
||||||
|
|||||||
61
frontend/src/types/apihub.ts
Normal file
61
frontend/src/types/apihub.ts
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
export interface DomainGroup {
|
||||||
|
domain: string;
|
||||||
|
iconPath: string | null;
|
||||||
|
sortOrder: number;
|
||||||
|
apis: ServiceApiItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiDomainInfo {
|
||||||
|
domainId: number;
|
||||||
|
domainName: string;
|
||||||
|
iconPath: string | null;
|
||||||
|
sortOrder: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceApiItem {
|
||||||
|
apiId: number;
|
||||||
|
serviceId: number;
|
||||||
|
apiPath: string;
|
||||||
|
apiMethod: string;
|
||||||
|
apiName: string;
|
||||||
|
apiDomain: string | null;
|
||||||
|
apiSection: string | null;
|
||||||
|
description: string | null;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceCatalog {
|
||||||
|
serviceId: number;
|
||||||
|
serviceCode: string;
|
||||||
|
serviceName: string;
|
||||||
|
description: string | null;
|
||||||
|
serviceUrl: string | null;
|
||||||
|
healthStatus: 'UP' | 'DOWN' | 'UNKNOWN';
|
||||||
|
apiCount: number;
|
||||||
|
domains: DomainGroup[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PopularApi {
|
||||||
|
domain: string;
|
||||||
|
apiName: string;
|
||||||
|
apiId: number | null;
|
||||||
|
serviceId: number | null;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecentApi {
|
||||||
|
apiId: number;
|
||||||
|
apiName: string;
|
||||||
|
apiPath: string;
|
||||||
|
apiMethod: string;
|
||||||
|
apiDomain: string | null;
|
||||||
|
description: string | null;
|
||||||
|
serviceId: number;
|
||||||
|
serviceCode: string;
|
||||||
|
serviceName: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
@ -65,6 +65,7 @@ export interface ApiKeyRequestReviewDto {
|
|||||||
adjustedApiIds?: number[];
|
adjustedApiIds?: number[];
|
||||||
adjustedFromDate?: string;
|
adjustedFromDate?: string;
|
||||||
adjustedToDate?: string;
|
adjustedToDate?: string;
|
||||||
|
adjustedDailyRequestLimit?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Permission {
|
export interface Permission {
|
||||||
|
|||||||
@ -97,3 +97,77 @@ export interface HealthHistory {
|
|||||||
errorMessage: string | null;
|
errorMessage: string | null;
|
||||||
checkedAt: string;
|
checkedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UpdateServiceApiRequest {
|
||||||
|
apiPath?: string;
|
||||||
|
apiMethod?: string;
|
||||||
|
apiName?: string;
|
||||||
|
apiDomain?: string;
|
||||||
|
apiSection?: string;
|
||||||
|
description?: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiSpecInfo {
|
||||||
|
specId: number;
|
||||||
|
apiId: number;
|
||||||
|
sampleUrl: string | null;
|
||||||
|
authRequired: boolean;
|
||||||
|
authType: string | null;
|
||||||
|
deprecated: boolean;
|
||||||
|
dataFormat: string | null;
|
||||||
|
referenceUrl: string | null;
|
||||||
|
note: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiParamInfo {
|
||||||
|
paramId: number;
|
||||||
|
apiId: number;
|
||||||
|
paramType: 'REQUEST' | 'RESPONSE';
|
||||||
|
paramName: string;
|
||||||
|
paramMeaning: string | null;
|
||||||
|
paramDescription: string | null;
|
||||||
|
required: boolean;
|
||||||
|
defaultValue: string | null;
|
||||||
|
inputType: string | null;
|
||||||
|
sortOrder: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiDetailInfo {
|
||||||
|
api: ServiceApi;
|
||||||
|
spec: ApiSpecInfo | null;
|
||||||
|
requestParams: ApiParamInfo[];
|
||||||
|
responseParams: ApiParamInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SaveApiSpecRequest {
|
||||||
|
sampleUrl?: string;
|
||||||
|
authRequired?: boolean;
|
||||||
|
authType?: string;
|
||||||
|
deprecated?: boolean;
|
||||||
|
dataFormat?: string;
|
||||||
|
referenceUrl?: string;
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SystemConfigInfo {
|
||||||
|
configId: number;
|
||||||
|
configKey: string;
|
||||||
|
configValue: string | null;
|
||||||
|
description: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SaveApiParamRequest {
|
||||||
|
paramType: 'REQUEST' | 'RESPONSE';
|
||||||
|
paramName: string;
|
||||||
|
paramMeaning?: string;
|
||||||
|
paramDescription?: string;
|
||||||
|
required?: boolean;
|
||||||
|
defaultValue?: string;
|
||||||
|
inputType?: string;
|
||||||
|
sortOrder?: number;
|
||||||
|
}
|
||||||
|
|||||||
55
scripts/init-domains.sh
Normal file
55
scripts/init-domains.sh
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# 도메인 초기 데이터 생성 스크립트
|
||||||
|
# 사용법: bash scripts/init-domains.sh
|
||||||
|
|
||||||
|
BASE_URL="http://localhost:8042/snp-connection/api/domains"
|
||||||
|
|
||||||
|
echo "=== 도메인 초기 데이터 생성 ==="
|
||||||
|
|
||||||
|
create_domain() {
|
||||||
|
local name="$1"
|
||||||
|
local icon="$2"
|
||||||
|
local order="$3"
|
||||||
|
echo -n " $name (sortOrder=$order) ... "
|
||||||
|
curl -s -X POST "$BASE_URL" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"domainName\":\"$name\",\"iconPath\":\"$icon\",\"sortOrder\":$order}" \
|
||||||
|
| python3 -c "import sys,json; d=json.load(sys.stdin); print('OK' if d.get('success') else d.get('message','FAIL'))" 2>/dev/null || echo "ERROR"
|
||||||
|
}
|
||||||
|
|
||||||
|
create_domain "SCREENING" \
|
||||||
|
"M10 1l2.39 4.843 5.346.777-3.868 3.77.913 5.323L10 13.347l-4.781 2.366.913-5.323L2.264 6.62l5.346-.777L10 1z" \
|
||||||
|
1
|
||||||
|
|
||||||
|
create_domain "AIS" \
|
||||||
|
"M10 2a6 6 0 00-6 6c0 4.5 6 10 6 10s6-5.5 6-10a6 6 0 00-6-6zm0 8a2 2 0 110-4 2 2 0 010 4z" \
|
||||||
|
2
|
||||||
|
|
||||||
|
create_domain "SHIP" \
|
||||||
|
"M3 15l1.5-6h11L17 15M5 15l-2 3h14l-2-3M7 9V5a1 1 0 011-1h4a1 1 0 011 1v4" \
|
||||||
|
3
|
||||||
|
|
||||||
|
create_domain "PORT" \
|
||||||
|
"M3 17h14M5 17V7l5-4 5 4v10M8 17v-3h4v3M8 10h.01M12 10h.01" \
|
||||||
|
4
|
||||||
|
|
||||||
|
create_domain "TRADE" \
|
||||||
|
"M4 6h12M4 6v10a1 1 0 001 1h10a1 1 0 001-1V6M4 6l1-3h10l1 3M8 10h4M8 13h4" \
|
||||||
|
5
|
||||||
|
|
||||||
|
create_domain "WEATHER" \
|
||||||
|
"M3 13.5c0-1.38 1.12-2.5 2.5-2.5.39 0 .76.09 1.09.25A4.002 4.002 0 0110.5 8c1.82 0 3.36 1.22 3.84 2.88A2.5 2.5 0 0117 13.5 2.5 2.5 0 0114.5 16h-9A2.5 2.5 0 013 13.5z" \
|
||||||
|
6
|
||||||
|
|
||||||
|
create_domain "COMPLIANCE" \
|
||||||
|
"M9 12l2 2 4-4m-3-5.96A8 8 0 1017.96 14H10V6.04z" \
|
||||||
|
7
|
||||||
|
|
||||||
|
create_domain "MONITORING" \
|
||||||
|
"M3 13h2l2-4 3 8 2-6 2 2h3M3 17h14" \
|
||||||
|
8
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== 완료 ==="
|
||||||
|
echo "기존 API의 api_domain 값과 도메인명을 일치시켜야 사이드바에 아이콘이 표시됩니다."
|
||||||
|
echo "Admin > Domains 에서 추가/수정/삭제할 수 있습니다."
|
||||||
@ -0,0 +1,76 @@
|
|||||||
|
package com.gcsc.connection.apihub.controller;
|
||||||
|
|
||||||
|
import com.gcsc.connection.apihub.dto.PopularApiResponse;
|
||||||
|
import com.gcsc.connection.apihub.dto.RecentApiResponse;
|
||||||
|
import com.gcsc.connection.apihub.dto.ServiceCatalogResponse;
|
||||||
|
import com.gcsc.connection.apihub.service.ApiHubService;
|
||||||
|
import com.gcsc.connection.common.dto.ApiResponse;
|
||||||
|
import com.gcsc.connection.service.dto.ApiDetailResponse;
|
||||||
|
import com.gcsc.connection.service.service.ServiceManagementService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API Hub 카탈로그 및 API 상세 조회 컨트롤러
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/api-hub")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ApiHubController {
|
||||||
|
|
||||||
|
private final ApiHubService apiHubService;
|
||||||
|
private final ServiceManagementService serviceManagementService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 활성 서비스와 해당 서비스의 활성 API를 도메인별로 그룹화하여 카탈로그 형태로 반환
|
||||||
|
*/
|
||||||
|
@GetMapping("/catalog")
|
||||||
|
public ResponseEntity<ApiResponse<List<ServiceCatalogResponse>>> getCatalog() {
|
||||||
|
List<ServiceCatalogResponse> catalog = apiHubService.getCatalog();
|
||||||
|
return ResponseEntity.ok(ApiResponse.ok(catalog));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 최근 등록된 활성 API 상위 10건 반환
|
||||||
|
*/
|
||||||
|
@GetMapping("/recent-apis")
|
||||||
|
public ResponseEntity<ApiResponse<List<RecentApiResponse>>> getRecentApis() {
|
||||||
|
List<RecentApiResponse> recentApis = apiHubService.getRecentApis();
|
||||||
|
return ResponseEntity.ok(ApiResponse.ok(recentApis));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 인기 API (최근 1주일 기준 호출 수 Top N)
|
||||||
|
*/
|
||||||
|
@GetMapping("/popular-apis")
|
||||||
|
public ResponseEntity<ApiResponse<List<PopularApiResponse>>> getPopularApis() {
|
||||||
|
List<PopularApiResponse> popularApis = apiHubService.getPopularApis(3);
|
||||||
|
return ResponseEntity.ok(ApiResponse.ok(popularApis));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 서비스 단건 카탈로그 조회
|
||||||
|
*/
|
||||||
|
@GetMapping("/services/{serviceId}")
|
||||||
|
public ResponseEntity<ApiResponse<ServiceCatalogResponse>> getServiceCatalog(
|
||||||
|
@PathVariable Long serviceId) {
|
||||||
|
ServiceCatalogResponse catalog = apiHubService.getServiceCatalog(serviceId);
|
||||||
|
return ResponseEntity.ok(ApiResponse.ok(catalog));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API 상세 명세 조회
|
||||||
|
*/
|
||||||
|
@GetMapping("/services/{serviceId}/apis/{apiId}")
|
||||||
|
public ResponseEntity<ApiResponse<ApiDetailResponse>> getApiDetail(
|
||||||
|
@PathVariable Long serviceId, @PathVariable Long apiId) {
|
||||||
|
ApiDetailResponse detail = serviceManagementService.getApiDetail(serviceId, apiId);
|
||||||
|
return ResponseEntity.ok(ApiResponse.ok(detail));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
package com.gcsc.connection.apihub.dto;
|
||||||
|
|
||||||
|
import com.gcsc.connection.service.dto.ServiceApiResponse;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public record DomainGroup(
|
||||||
|
String domain,
|
||||||
|
String iconPath,
|
||||||
|
int sortOrder,
|
||||||
|
List<ServiceApiResponse> apis
|
||||||
|
) {
|
||||||
|
}
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
package com.gcsc.connection.apihub.dto;
|
||||||
|
|
||||||
|
public record PopularApiResponse(
|
||||||
|
String domain,
|
||||||
|
String apiName,
|
||||||
|
Long apiId,
|
||||||
|
Long serviceId,
|
||||||
|
long count
|
||||||
|
) {
|
||||||
|
}
|
||||||
@ -0,0 +1,34 @@
|
|||||||
|
package com.gcsc.connection.apihub.dto;
|
||||||
|
|
||||||
|
import com.gcsc.connection.service.entity.SnpServiceApi;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
public record RecentApiResponse(
|
||||||
|
Long apiId,
|
||||||
|
String apiName,
|
||||||
|
String apiPath,
|
||||||
|
String apiMethod,
|
||||||
|
String apiDomain,
|
||||||
|
String description,
|
||||||
|
Long serviceId,
|
||||||
|
String serviceCode,
|
||||||
|
String serviceName,
|
||||||
|
LocalDateTime createdAt
|
||||||
|
) {
|
||||||
|
|
||||||
|
public static RecentApiResponse from(SnpServiceApi api) {
|
||||||
|
return new RecentApiResponse(
|
||||||
|
api.getApiId(),
|
||||||
|
api.getApiName(),
|
||||||
|
api.getApiPath(),
|
||||||
|
api.getApiMethod(),
|
||||||
|
api.getApiDomain(),
|
||||||
|
api.getDescription(),
|
||||||
|
api.getService().getServiceId(),
|
||||||
|
api.getService().getServiceCode(),
|
||||||
|
api.getService().getServiceName(),
|
||||||
|
api.getCreatedAt()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,73 @@
|
|||||||
|
package com.gcsc.connection.apihub.dto;
|
||||||
|
|
||||||
|
import com.gcsc.connection.service.dto.ServiceApiResponse;
|
||||||
|
import com.gcsc.connection.service.entity.SnpApiDomain;
|
||||||
|
import com.gcsc.connection.service.entity.SnpService;
|
||||||
|
import com.gcsc.connection.service.entity.SnpServiceApi;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
public record ServiceCatalogResponse(
|
||||||
|
Long serviceId,
|
||||||
|
String serviceCode,
|
||||||
|
String serviceName,
|
||||||
|
String serviceUrl,
|
||||||
|
String description,
|
||||||
|
String healthStatus,
|
||||||
|
int apiCount,
|
||||||
|
List<DomainGroup> domains
|
||||||
|
) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 도메인 메타 정보(아이콘, 정렬) 없이 도메인명 기준 알파벳 정렬로 카탈로그 생성
|
||||||
|
*/
|
||||||
|
public static ServiceCatalogResponse from(SnpService service, List<SnpServiceApi> apis) {
|
||||||
|
return from(service, apis, Map.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 도메인 메타 정보(아이콘, 정렬)를 포함하여 카탈로그 생성
|
||||||
|
*
|
||||||
|
* @param domainMap domainName → SnpApiDomain 매핑
|
||||||
|
*/
|
||||||
|
public static ServiceCatalogResponse from(SnpService service, List<SnpServiceApi> apis,
|
||||||
|
Map<String, SnpApiDomain> domainMap) {
|
||||||
|
Map<String, List<ServiceApiResponse>> byDomain = apis.stream()
|
||||||
|
.collect(Collectors.groupingBy(
|
||||||
|
api -> api.getApiDomain() != null ? api.getApiDomain() : "",
|
||||||
|
Collectors.mapping(ServiceApiResponse::from, Collectors.toList())
|
||||||
|
));
|
||||||
|
|
||||||
|
List<DomainGroup> domainGroups = byDomain.entrySet().stream()
|
||||||
|
.sorted((a, b) -> {
|
||||||
|
SnpApiDomain da = domainMap.get(a.getKey());
|
||||||
|
SnpApiDomain db = domainMap.get(b.getKey());
|
||||||
|
int orderA = da != null ? da.getSortOrder() : Integer.MAX_VALUE;
|
||||||
|
int orderB = db != null ? db.getSortOrder() : Integer.MAX_VALUE;
|
||||||
|
if (orderA != orderB) {
|
||||||
|
return Integer.compare(orderA, orderB);
|
||||||
|
}
|
||||||
|
return a.getKey().compareTo(b.getKey());
|
||||||
|
})
|
||||||
|
.map(entry -> {
|
||||||
|
SnpApiDomain domainMeta = domainMap.get(entry.getKey());
|
||||||
|
String iconPath = domainMeta != null ? domainMeta.getIconPath() : null;
|
||||||
|
int sortOrder = domainMeta != null ? domainMeta.getSortOrder() : Integer.MAX_VALUE;
|
||||||
|
return new DomainGroup(entry.getKey(), iconPath, sortOrder, entry.getValue());
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
return new ServiceCatalogResponse(
|
||||||
|
service.getServiceId(),
|
||||||
|
service.getServiceCode(),
|
||||||
|
service.getServiceName(),
|
||||||
|
service.getServiceUrl(),
|
||||||
|
service.getDescription(),
|
||||||
|
service.getHealthStatus().name(),
|
||||||
|
apis.size(),
|
||||||
|
domainGroups
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,100 @@
|
|||||||
|
package com.gcsc.connection.apihub.service;
|
||||||
|
|
||||||
|
import com.gcsc.connection.apihub.dto.RecentApiResponse;
|
||||||
|
import com.gcsc.connection.apihub.dto.ServiceCatalogResponse;
|
||||||
|
import com.gcsc.connection.common.exception.BusinessException;
|
||||||
|
import com.gcsc.connection.common.exception.ErrorCode;
|
||||||
|
import com.gcsc.connection.service.entity.SnpApiDomain;
|
||||||
|
import com.gcsc.connection.service.entity.SnpService;
|
||||||
|
import com.gcsc.connection.service.entity.SnpServiceApi;
|
||||||
|
import com.gcsc.connection.service.repository.SnpApiDomainRepository;
|
||||||
|
import com.gcsc.connection.service.repository.SnpServiceApiRepository;
|
||||||
|
import com.gcsc.connection.service.repository.SnpServiceRepository;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import com.gcsc.connection.monitoring.repository.SnpApiRequestLogRepository;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.function.Function;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@Slf4j
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ApiHubService {
|
||||||
|
|
||||||
|
private final SnpServiceRepository snpServiceRepository;
|
||||||
|
private final SnpServiceApiRepository snpServiceApiRepository;
|
||||||
|
private final SnpApiDomainRepository snpApiDomainRepository;
|
||||||
|
private final SnpApiRequestLogRepository snpApiRequestLogRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 활성 서비스와 각 서비스의 활성 API를 도메인별로 그룹화하여 카탈로그 반환
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<ServiceCatalogResponse> getCatalog() {
|
||||||
|
Map<String, SnpApiDomain> domainMap = buildDomainMap();
|
||||||
|
List<SnpService> activeServices = snpServiceRepository.findByIsActiveTrue();
|
||||||
|
|
||||||
|
return activeServices.stream()
|
||||||
|
.map(service -> {
|
||||||
|
List<SnpServiceApi> activeApis = snpServiceApiRepository
|
||||||
|
.findByServiceServiceIdAndIsActiveTrue(service.getServiceId());
|
||||||
|
return ServiceCatalogResponse.from(service, activeApis, domainMap);
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 서비스 단건 카탈로그 조회
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public ServiceCatalogResponse getServiceCatalog(Long serviceId) {
|
||||||
|
SnpService service = snpServiceRepository.findById(serviceId)
|
||||||
|
.filter(SnpService::getIsActive)
|
||||||
|
.orElseThrow(() -> new BusinessException(ErrorCode.SERVICE_NOT_FOUND));
|
||||||
|
|
||||||
|
List<SnpServiceApi> activeApis = snpServiceApiRepository
|
||||||
|
.findByServiceServiceIdAndIsActiveTrue(serviceId);
|
||||||
|
|
||||||
|
Map<String, SnpApiDomain> domainMap = buildDomainMap();
|
||||||
|
return ServiceCatalogResponse.from(service, activeApis, domainMap);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 최근 등록된 활성 API 상위 10건 반환
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<RecentApiResponse> getRecentApis() {
|
||||||
|
return snpServiceApiRepository.findTop10ByIsActiveTrueOrderByCreatedAtDesc().stream()
|
||||||
|
.map(RecentApiResponse::from)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 최근 1주일 기준 인기 API 상위 N건
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<com.gcsc.connection.apihub.dto.PopularApiResponse> getPopularApis(int limit) {
|
||||||
|
LocalDateTime since = LocalDateTime.now().minusDays(7);
|
||||||
|
return snpApiRequestLogRepository.findTopApisForHub(since, limit).stream()
|
||||||
|
.map(row -> new com.gcsc.connection.apihub.dto.PopularApiResponse(
|
||||||
|
(String) row[0],
|
||||||
|
(String) row[1],
|
||||||
|
row[2] != null ? ((Number) row[2]).longValue() : null,
|
||||||
|
row[3] != null ? ((Number) row[3]).longValue() : null,
|
||||||
|
((Number) row[4]).longValue()
|
||||||
|
))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, SnpApiDomain> buildDomainMap() {
|
||||||
|
return snpApiDomainRepository.findAllByOrderBySortOrderAscDomainNameAsc().stream()
|
||||||
|
.collect(Collectors.toMap(SnpApiDomain::getDomainName, Function.identity()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -9,6 +9,7 @@ public record ApiKeyRequestReviewDto(
|
|||||||
String reviewComment,
|
String reviewComment,
|
||||||
List<Long> adjustedApiIds,
|
List<Long> adjustedApiIds,
|
||||||
String adjustedFromDate,
|
String adjustedFromDate,
|
||||||
String adjustedToDate
|
String adjustedToDate,
|
||||||
|
Long adjustedDailyRequestLimit
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,7 @@ package com.gcsc.connection.apikey.dto;
|
|||||||
import jakarta.validation.constraints.NotBlank;
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
|
||||||
public record CreateApiKeyRequest(
|
public record CreateApiKeyRequest(
|
||||||
@NotBlank String keyName
|
@NotBlank String keyName,
|
||||||
|
Long dailyRequestLimit
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|||||||
@ -60,10 +60,13 @@ public class SnpApiKey extends BaseEntity {
|
|||||||
@Column(name = "last_used_at")
|
@Column(name = "last_used_at")
|
||||||
private LocalDateTime lastUsedAt;
|
private LocalDateTime lastUsedAt;
|
||||||
|
|
||||||
|
@Column(name = "daily_request_limit")
|
||||||
|
private Long dailyRequestLimit;
|
||||||
|
|
||||||
@Builder
|
@Builder
|
||||||
public SnpApiKey(SnpUser user, String apiKey, String apiKeyPrefix, String keyName,
|
public SnpApiKey(SnpUser user, String apiKey, String apiKeyPrefix, String keyName,
|
||||||
ApiKeyStatus status, SnpUser approvedBy, LocalDateTime approvedAt,
|
ApiKeyStatus status, SnpUser approvedBy, LocalDateTime approvedAt,
|
||||||
LocalDateTime expiresAt) {
|
LocalDateTime expiresAt, Long dailyRequestLimit) {
|
||||||
this.user = user;
|
this.user = user;
|
||||||
this.apiKey = apiKey;
|
this.apiKey = apiKey;
|
||||||
this.apiKeyPrefix = apiKeyPrefix;
|
this.apiKeyPrefix = apiKeyPrefix;
|
||||||
@ -72,6 +75,7 @@ public class SnpApiKey extends BaseEntity {
|
|||||||
this.approvedBy = approvedBy;
|
this.approvedBy = approvedBy;
|
||||||
this.approvedAt = approvedAt;
|
this.approvedAt = approvedAt;
|
||||||
this.expiresAt = expiresAt;
|
this.expiresAt = expiresAt;
|
||||||
|
this.dailyRequestLimit = dailyRequestLimit;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void revoke() {
|
public void revoke() {
|
||||||
|
|||||||
@ -150,6 +150,11 @@ public class ApiKeyRequestService {
|
|||||||
String prefix = rawKey.substring(0, PREFIX_LENGTH);
|
String prefix = rawKey.substring(0, PREFIX_LENGTH);
|
||||||
String encryptedKey = aesEncryptor.encrypt(rawKey);
|
String encryptedKey = aesEncryptor.encrypt(rawKey);
|
||||||
|
|
||||||
|
// 일일 요청 제한: 검토자가 조정한 값 > 신청자 입력값 > null(무제한)
|
||||||
|
Long dailyLimit = dto.adjustedDailyRequestLimit() != null
|
||||||
|
? dto.adjustedDailyRequestLimit()
|
||||||
|
: request.getDailyRequestEstimate();
|
||||||
|
|
||||||
SnpApiKey apiKey = SnpApiKey.builder()
|
SnpApiKey apiKey = SnpApiKey.builder()
|
||||||
.user(request.getUser())
|
.user(request.getUser())
|
||||||
.apiKey(encryptedKey)
|
.apiKey(encryptedKey)
|
||||||
@ -159,6 +164,7 @@ public class ApiKeyRequestService {
|
|||||||
.approvedBy(reviewer)
|
.approvedBy(reviewer)
|
||||||
.approvedAt(LocalDateTime.now())
|
.approvedAt(LocalDateTime.now())
|
||||||
.expiresAt(request.getUsageToDate())
|
.expiresAt(request.getUsageToDate())
|
||||||
|
.dailyRequestLimit(dailyLimit)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
SnpApiKey savedKey = snpApiKeyRepository.save(apiKey);
|
SnpApiKey savedKey = snpApiKeyRepository.save(apiKey);
|
||||||
|
|||||||
@ -75,6 +75,7 @@ public class ApiKeyService {
|
|||||||
.apiKeyPrefix(prefix)
|
.apiKeyPrefix(prefix)
|
||||||
.keyName(request.keyName())
|
.keyName(request.keyName())
|
||||||
.status(ApiKeyStatus.ACTIVE)
|
.status(ApiKeyStatus.ACTIVE)
|
||||||
|
.dailyRequestLimit(request.dailyRequestLimit())
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
SnpApiKey saved = snpApiKeyRepository.save(apiKey);
|
SnpApiKey saved = snpApiKeyRepository.save(apiKey);
|
||||||
|
|||||||
@ -0,0 +1,46 @@
|
|||||||
|
package com.gcsc.connection.common.controller;
|
||||||
|
|
||||||
|
import com.gcsc.connection.common.dto.ApiResponse;
|
||||||
|
import com.gcsc.connection.common.dto.SystemConfigResponse;
|
||||||
|
import com.gcsc.connection.common.dto.UpdateSystemConfigRequest;
|
||||||
|
import com.gcsc.connection.common.service.SystemConfigService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 시스템 공통 설정 API
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/config")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class SystemConfigController {
|
||||||
|
|
||||||
|
private final SystemConfigService systemConfigService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 설정 값 단건 조회
|
||||||
|
*/
|
||||||
|
@GetMapping("/{configKey}")
|
||||||
|
public ResponseEntity<ApiResponse<SystemConfigResponse>> getConfig(@PathVariable String configKey) {
|
||||||
|
SystemConfigResponse response = systemConfigService.getConfigValue(configKey);
|
||||||
|
return ResponseEntity.ok(ApiResponse.ok(response));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 설정 값 저장 (upsert)
|
||||||
|
*/
|
||||||
|
@PutMapping("/{configKey}")
|
||||||
|
public ResponseEntity<ApiResponse<SystemConfigResponse>> updateConfig(
|
||||||
|
@PathVariable String configKey,
|
||||||
|
@RequestBody UpdateSystemConfigRequest request
|
||||||
|
) {
|
||||||
|
SystemConfigResponse response = systemConfigService.updateConfig(configKey, request);
|
||||||
|
return ResponseEntity.ok(ApiResponse.ok(response));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -10,6 +10,7 @@ import lombok.Getter;
|
|||||||
public class ApiResponse<T> {
|
public class ApiResponse<T> {
|
||||||
|
|
||||||
private final boolean success;
|
private final boolean success;
|
||||||
|
private final String code;
|
||||||
private final String message;
|
private final String message;
|
||||||
private final T data;
|
private final T data;
|
||||||
|
|
||||||
@ -34,4 +35,12 @@ public class ApiResponse<T> {
|
|||||||
.message(message)
|
.message(message)
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static <T> ApiResponse<T> error(String code, String message) {
|
||||||
|
return ApiResponse.<T>builder()
|
||||||
|
.success(false)
|
||||||
|
.code(code)
|
||||||
|
.message(message)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,26 @@
|
|||||||
|
package com.gcsc.connection.common.dto;
|
||||||
|
|
||||||
|
import com.gcsc.connection.common.entity.SnpSystemConfig;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
public record SystemConfigResponse(
|
||||||
|
Long configId,
|
||||||
|
String configKey,
|
||||||
|
String configValue,
|
||||||
|
String description,
|
||||||
|
LocalDateTime createdAt,
|
||||||
|
LocalDateTime updatedAt
|
||||||
|
) {
|
||||||
|
|
||||||
|
public static SystemConfigResponse from(SnpSystemConfig config) {
|
||||||
|
return new SystemConfigResponse(
|
||||||
|
config.getConfigId(),
|
||||||
|
config.getConfigKey(),
|
||||||
|
config.getConfigValue(),
|
||||||
|
config.getDescription(),
|
||||||
|
config.getCreatedAt(),
|
||||||
|
config.getUpdatedAt()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
package com.gcsc.connection.common.dto;
|
||||||
|
|
||||||
|
public record UpdateSystemConfigRequest(
|
||||||
|
String configValue
|
||||||
|
) {
|
||||||
|
}
|
||||||
@ -0,0 +1,44 @@
|
|||||||
|
package com.gcsc.connection.common.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.GeneratedValue;
|
||||||
|
import jakarta.persistence.GenerationType;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import lombok.AccessLevel;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
||||||
|
@Entity
|
||||||
|
@Table(name = "snp_system_config", schema = "common")
|
||||||
|
public class SnpSystemConfig extends BaseEntity {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
@Column(name = "config_id")
|
||||||
|
private Long configId;
|
||||||
|
|
||||||
|
@Column(name = "config_key", unique = true, nullable = false, length = 100)
|
||||||
|
private String configKey;
|
||||||
|
|
||||||
|
@Column(name = "config_value", columnDefinition = "TEXT")
|
||||||
|
private String configValue;
|
||||||
|
|
||||||
|
@Column(name = "description", length = 500)
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
public SnpSystemConfig(String configKey, String configValue, String description) {
|
||||||
|
this.configKey = configKey;
|
||||||
|
this.configValue = configValue;
|
||||||
|
this.description = description;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void update(String configValue) {
|
||||||
|
this.configValue = configValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -31,6 +31,7 @@ public enum ErrorCode {
|
|||||||
GATEWAY_SERVICE_INACTIVE(503, "GW004", "비활성 서비스입니다"),
|
GATEWAY_SERVICE_INACTIVE(503, "GW004", "비활성 서비스입니다"),
|
||||||
GATEWAY_PERMISSION_DENIED(403, "GW005", "해당 API에 대한 권한이 없습니다"),
|
GATEWAY_PERMISSION_DENIED(403, "GW005", "해당 API에 대한 권한이 없습니다"),
|
||||||
GATEWAY_PROXY_FAILED(502, "GW006", "서비스 요청에 실패했습니다"),
|
GATEWAY_PROXY_FAILED(502, "GW006", "서비스 요청에 실패했습니다"),
|
||||||
|
GATEWAY_DAILY_LIMIT_EXCEEDED(429, "GW007", "일일 최대 호출 건수 제한으로 사용할 수 없습니다"),
|
||||||
INTERNAL_ERROR(500, "SYS001", "시스템 오류가 발생했습니다");
|
INTERNAL_ERROR(500, "SYS001", "시스템 오류가 발생했습니다");
|
||||||
|
|
||||||
private final int status;
|
private final int status;
|
||||||
|
|||||||
@ -26,7 +26,7 @@ public class GlobalExceptionHandler {
|
|||||||
log.warn("Business exception: {} - {}", errorCode.getCode(), errorCode.getMessage());
|
log.warn("Business exception: {} - {}", errorCode.getCode(), errorCode.getMessage());
|
||||||
return ResponseEntity
|
return ResponseEntity
|
||||||
.status(errorCode.getStatus())
|
.status(errorCode.getStatus())
|
||||||
.body(ApiResponse.error(errorCode.getMessage()));
|
.body(ApiResponse.error(errorCode.getCode(), errorCode.getMessage()));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -87,6 +87,15 @@ public class GlobalExceptionHandler {
|
|||||||
.body(ApiResponse.error("요청 본문을 읽을 수 없습니다"));
|
.body(ApiResponse.error("요청 본문을 읽을 수 없습니다"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 정적 리소스 미발견 (이미지 등 404)
|
||||||
|
*/
|
||||||
|
@ExceptionHandler(org.springframework.web.servlet.resource.NoResourceFoundException.class)
|
||||||
|
public ResponseEntity<Void> handleNoResourceFound(
|
||||||
|
org.springframework.web.servlet.resource.NoResourceFoundException e) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 처리되지 않은 예외 처리
|
* 처리되지 않은 예외 처리
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -0,0 +1,11 @@
|
|||||||
|
package com.gcsc.connection.common.repository;
|
||||||
|
|
||||||
|
import com.gcsc.connection.common.entity.SnpSystemConfig;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public interface SnpSystemConfigRepository extends JpaRepository<SnpSystemConfig, Long> {
|
||||||
|
|
||||||
|
Optional<SnpSystemConfig> findByConfigKey(String configKey);
|
||||||
|
}
|
||||||
@ -0,0 +1,48 @@
|
|||||||
|
package com.gcsc.connection.common.service;
|
||||||
|
|
||||||
|
import com.gcsc.connection.common.dto.SystemConfigResponse;
|
||||||
|
import com.gcsc.connection.common.dto.UpdateSystemConfigRequest;
|
||||||
|
import com.gcsc.connection.common.entity.SnpSystemConfig;
|
||||||
|
import com.gcsc.connection.common.repository.SnpSystemConfigRepository;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class SystemConfigService {
|
||||||
|
|
||||||
|
private final SnpSystemConfigRepository systemConfigRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 설정 값 단건 조회
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public SystemConfigResponse getConfigValue(String configKey) {
|
||||||
|
return systemConfigRepository.findByConfigKey(configKey)
|
||||||
|
.map(SystemConfigResponse::from)
|
||||||
|
.orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 설정 값 저장 (upsert: 없으면 생성, 있으면 수정)
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public SystemConfigResponse updateConfig(String configKey, UpdateSystemConfigRequest request) {
|
||||||
|
SnpSystemConfig config = systemConfigRepository.findByConfigKey(configKey)
|
||||||
|
.map(existing -> {
|
||||||
|
existing.update(request.configValue());
|
||||||
|
return existing;
|
||||||
|
})
|
||||||
|
.orElseGet(() -> systemConfigRepository.save(
|
||||||
|
SnpSystemConfig.builder()
|
||||||
|
.configKey(configKey)
|
||||||
|
.configValue(request.configValue())
|
||||||
|
.build()
|
||||||
|
));
|
||||||
|
log.info("시스템 설정 저장: configKey={}", configKey);
|
||||||
|
return SystemConfigResponse.from(config);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -37,7 +37,7 @@ public class GatewayController {
|
|||||||
String remainingPath = extractRemainingPath(serviceCode, request);
|
String remainingPath = extractRemainingPath(serviceCode, request);
|
||||||
return gatewayService.proxyRequest(serviceCode, remainingPath, request);
|
return gatewayService.proxyRequest(serviceCode, remainingPath, request);
|
||||||
} catch (BusinessException e) {
|
} catch (BusinessException e) {
|
||||||
return buildErrorResponse(e.getErrorCode().getStatus(), e.getErrorCode().getMessage());
|
return buildErrorResponse(e.getErrorCode().getStatus(), e.getErrorCode().getCode(), e.getErrorCode().getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -60,8 +60,8 @@ public class GatewayController {
|
|||||||
/**
|
/**
|
||||||
* Gateway 소비자용 JSON 에러 응답 생성
|
* Gateway 소비자용 JSON 에러 응답 생성
|
||||||
*/
|
*/
|
||||||
private ResponseEntity<byte[]> buildErrorResponse(int status, String message) {
|
private ResponseEntity<byte[]> buildErrorResponse(int status, String code, String message) {
|
||||||
String json = "{\"success\":false,\"message\":\"" + escapeJson(message) + "\"}";
|
String json = "{\"success\":false,\"code\":\"" + escapeJson(code) + "\",\"message\":\"" + escapeJson(message) + "\"}";
|
||||||
return ResponseEntity.status(status)
|
return ResponseEntity.status(status)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.body(json.getBytes());
|
.body(json.getBytes());
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import com.gcsc.connection.common.exception.BusinessException;
|
|||||||
import com.gcsc.connection.common.exception.ErrorCode;
|
import com.gcsc.connection.common.exception.ErrorCode;
|
||||||
import com.gcsc.connection.common.util.AesEncryptor;
|
import com.gcsc.connection.common.util.AesEncryptor;
|
||||||
import com.gcsc.connection.monitoring.entity.SnpApiRequestLog;
|
import com.gcsc.connection.monitoring.entity.SnpApiRequestLog;
|
||||||
|
import com.gcsc.connection.monitoring.repository.SnpApiRequestLogRepository;
|
||||||
import com.gcsc.connection.monitoring.service.RequestLogService;
|
import com.gcsc.connection.monitoring.service.RequestLogService;
|
||||||
import com.gcsc.connection.service.entity.SnpService;
|
import com.gcsc.connection.service.entity.SnpService;
|
||||||
import com.gcsc.connection.service.entity.SnpServiceApi;
|
import com.gcsc.connection.service.entity.SnpServiceApi;
|
||||||
@ -23,6 +24,7 @@ import org.springframework.stereotype.Service;
|
|||||||
import org.springframework.web.reactive.function.client.WebClient;
|
import org.springframework.web.reactive.function.client.WebClient;
|
||||||
import org.springframework.web.reactive.function.client.WebClientResponseException;
|
import org.springframework.web.reactive.function.client.WebClientResponseException;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.Enumeration;
|
import java.util.Enumeration;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@ -34,8 +36,9 @@ import java.util.Set;
|
|||||||
public class GatewayService {
|
public class GatewayService {
|
||||||
|
|
||||||
private static final int API_KEY_PREFIX_LENGTH = 8;
|
private static final int API_KEY_PREFIX_LENGTH = 8;
|
||||||
|
private static final String AUTH_KEY_PARAM = "authKey";
|
||||||
private static final Set<String> EXCLUDED_HEADERS = Set.of(
|
private static final Set<String> EXCLUDED_HEADERS = Set.of(
|
||||||
"host", "x-api-key", "connection", "content-length"
|
"host", "connection", "content-length"
|
||||||
);
|
);
|
||||||
|
|
||||||
private final SnpApiKeyRepository snpApiKeyRepository;
|
private final SnpApiKeyRepository snpApiKeyRepository;
|
||||||
@ -45,6 +48,7 @@ public class GatewayService {
|
|||||||
private final AesEncryptor aesEncryptor;
|
private final AesEncryptor aesEncryptor;
|
||||||
private final WebClient webClient;
|
private final WebClient webClient;
|
||||||
private final RequestLogService requestLogService;
|
private final RequestLogService requestLogService;
|
||||||
|
private final SnpApiRequestLogRepository snpApiRequestLogRepository;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* API Gateway 프록시 요청 처리
|
* API Gateway 프록시 요청 처리
|
||||||
@ -70,8 +74,8 @@ public class GatewayService {
|
|||||||
// 2. 대상 URL 조합 (실패 로그에도 사용)
|
// 2. 대상 URL 조합 (실패 로그에도 사용)
|
||||||
targetUrl = buildTargetUrl(service.getServiceUrl(), remainingPath, request);
|
targetUrl = buildTargetUrl(service.getServiceUrl(), remainingPath, request);
|
||||||
|
|
||||||
// 3. API Key 추출
|
// 3. API Key 추출 (쿼리 파라미터 authKey)
|
||||||
String rawKey = request.getHeader("X-API-KEY");
|
String rawKey = request.getParameter(AUTH_KEY_PARAM);
|
||||||
if (rawKey == null || rawKey.isBlank()) {
|
if (rawKey == null || rawKey.isBlank()) {
|
||||||
throw new BusinessException(ErrorCode.GATEWAY_API_KEY_MISSING);
|
throw new BusinessException(ErrorCode.GATEWAY_API_KEY_MISSING);
|
||||||
}
|
}
|
||||||
@ -82,7 +86,10 @@ public class GatewayService {
|
|||||||
// 5. Key 상태/만료 검증
|
// 5. Key 상태/만료 검증
|
||||||
validateApiKey(apiKey);
|
validateApiKey(apiKey);
|
||||||
|
|
||||||
// 6. ServiceApi 조회 (경로 + 메서드 매칭, {변수} 패턴 지원)
|
// 6. 일일 요청량 제한 검증
|
||||||
|
validateDailyLimit(apiKey);
|
||||||
|
|
||||||
|
// 7. ServiceApi 조회 (경로 + 메서드 매칭, {변수} 패턴 지원)
|
||||||
String apiPath = remainingPath.startsWith("/") ? remainingPath : "/" + remainingPath;
|
String apiPath = remainingPath.startsWith("/") ? remainingPath : "/" + remainingPath;
|
||||||
SnpServiceApi serviceApi = matchServiceApi(service.getServiceId(), apiPath, request.getMethod());
|
SnpServiceApi serviceApi = matchServiceApi(service.getServiceId(), apiPath, request.getMethod());
|
||||||
|
|
||||||
@ -109,7 +116,8 @@ public class GatewayService {
|
|||||||
|
|
||||||
} catch (BusinessException e) {
|
} catch (BusinessException e) {
|
||||||
int responseTime = (int) (System.currentTimeMillis() - startTime);
|
int responseTime = (int) (System.currentTimeMillis() - startTime);
|
||||||
saveLog(request, service, apiKey, targetUrl, gatewayPath, "FAIL",
|
String logStatus = isDeniedError(e.getErrorCode()) ? "DENIED" : "FAIL";
|
||||||
|
saveLog(request, service, apiKey, targetUrl, gatewayPath, logStatus,
|
||||||
e.getErrorCode().getStatus(), responseTime, 0L,
|
e.getErrorCode().getStatus(), responseTime, 0L,
|
||||||
e.getErrorCode().getMessage(), requestedAt);
|
e.getErrorCode().getMessage(), requestedAt);
|
||||||
throw e;
|
throw e;
|
||||||
@ -192,6 +200,36 @@ public class GatewayService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static final Set<ErrorCode> DENIED_ERROR_CODES = Set.of(
|
||||||
|
ErrorCode.GATEWAY_API_KEY_MISSING,
|
||||||
|
ErrorCode.GATEWAY_API_KEY_INVALID,
|
||||||
|
ErrorCode.GATEWAY_API_KEY_EXPIRED,
|
||||||
|
ErrorCode.GATEWAY_PERMISSION_DENIED,
|
||||||
|
ErrorCode.GATEWAY_DAILY_LIMIT_EXCEEDED
|
||||||
|
);
|
||||||
|
|
||||||
|
private boolean isDeniedError(ErrorCode errorCode) {
|
||||||
|
return DENIED_ERROR_CODES.contains(errorCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 일일 요청량 제한 검증
|
||||||
|
*/
|
||||||
|
private void validateDailyLimit(SnpApiKey apiKey) {
|
||||||
|
Long limit = apiKey.getDailyRequestLimit();
|
||||||
|
if (limit == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
LocalDateTime startOfDay = LocalDate.now().atStartOfDay();
|
||||||
|
long todayCount = snpApiRequestLogRepository
|
||||||
|
.countByApiKeyApiKeyIdAndRequestedAtGreaterThanEqual(apiKey.getApiKeyId(), startOfDay);
|
||||||
|
|
||||||
|
if (todayCount >= limit) {
|
||||||
|
throw new BusinessException(ErrorCode.GATEWAY_DAILY_LIMIT_EXCEEDED);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 대상 URL 구성
|
* 대상 URL 구성
|
||||||
*/
|
*/
|
||||||
@ -206,7 +244,13 @@ public class GatewayService {
|
|||||||
|
|
||||||
String queryString = request.getQueryString();
|
String queryString = request.getQueryString();
|
||||||
if (queryString != null && !queryString.isEmpty()) {
|
if (queryString != null && !queryString.isEmpty()) {
|
||||||
url.append("?").append(queryString);
|
// authKey 파라미터는 프록시 대상에 전달하지 않음
|
||||||
|
String filtered = java.util.Arrays.stream(queryString.split("&"))
|
||||||
|
.filter(p -> !p.startsWith(AUTH_KEY_PARAM + "="))
|
||||||
|
.collect(java.util.stream.Collectors.joining("&"));
|
||||||
|
if (!filtered.isEmpty()) {
|
||||||
|
url.append("?").append(filtered);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return url.toString();
|
return url.toString();
|
||||||
@ -327,10 +371,8 @@ public class GatewayService {
|
|||||||
Enumeration<String> headerNames = request.getHeaderNames();
|
Enumeration<String> headerNames = request.getHeaderNames();
|
||||||
while (headerNames.hasMoreElements()) {
|
while (headerNames.hasMoreElements()) {
|
||||||
String name = headerNames.nextElement();
|
String name = headerNames.nextElement();
|
||||||
if (!"x-api-key".equalsIgnoreCase(name)) {
|
|
||||||
sb.append(name).append(": ").append(request.getHeader(name)).append("\n");
|
sb.append(name).append(": ").append(request.getHeader(name)).append("\n");
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return sb.toString();
|
return sb.toString();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,7 +20,8 @@ public class WebViewController {
|
|||||||
@GetMapping({"/dashboard", "/dashboard/**",
|
@GetMapping({"/dashboard", "/dashboard/**",
|
||||||
"/monitoring/**", "/statistics/**",
|
"/monitoring/**", "/statistics/**",
|
||||||
"/apikeys", "/apikeys/**",
|
"/apikeys", "/apikeys/**",
|
||||||
"/admin/**"})
|
"/admin/**",
|
||||||
|
"/api-hub", "/api-hub/**"})
|
||||||
public String forward() {
|
public String forward() {
|
||||||
return "forward:/index.html";
|
return "forward:/index.html";
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,6 +12,23 @@ import java.util.List;
|
|||||||
public interface SnpApiRequestLogRepository extends JpaRepository<SnpApiRequestLog, Long>,
|
public interface SnpApiRequestLogRepository extends JpaRepository<SnpApiRequestLog, Long>,
|
||||||
JpaSpecificationExecutor<SnpApiRequestLog> {
|
JpaSpecificationExecutor<SnpApiRequestLog> {
|
||||||
|
|
||||||
|
/** API HUB 인기 API (최근 1주일, 도메인 포함) */
|
||||||
|
@Query(value = "SELECT COALESCE(a.api_domain, '') as domain, " +
|
||||||
|
"COALESCE(a.api_name, SPLIT_PART(l.request_url, '?', 1)) as apiName, " +
|
||||||
|
"a.api_id, a.service_id, COUNT(*) as cnt " +
|
||||||
|
"FROM common.snp_api_request_log l " +
|
||||||
|
"LEFT JOIN common.snp_service s ON l.service_id = s.service_id " +
|
||||||
|
"LEFT JOIN common.snp_service_api a ON s.service_id = a.service_id " +
|
||||||
|
"AND a.api_path = SUBSTRING(SPLIT_PART(l.request_url, '?', 1) FROM '/gateway/[^/]+(.*)') " +
|
||||||
|
"AND a.api_method = l.request_method " +
|
||||||
|
"WHERE l.requested_at >= :since AND a.api_id IS NOT NULL " +
|
||||||
|
"GROUP BY a.api_domain, a.api_name, a.api_id, a.service_id, SPLIT_PART(l.request_url, '?', 1) " +
|
||||||
|
"ORDER BY cnt DESC LIMIT :limit", nativeQuery = true)
|
||||||
|
List<Object[]> findTopApisForHub(@Param("since") LocalDateTime since, @Param("limit") int limit);
|
||||||
|
|
||||||
|
/** API Key별 일일 요청 건수 */
|
||||||
|
long countByApiKeyApiKeyIdAndRequestedAtGreaterThanEqual(Long apiKeyId, LocalDateTime startOfDay);
|
||||||
|
|
||||||
/** 오늘 요약: 총 요청, 성공 건수, 평균 응답시간 */
|
/** 오늘 요약: 총 요청, 성공 건수, 평균 응답시간 */
|
||||||
@Query(value = "SELECT COUNT(*) as total, " +
|
@Query(value = "SELECT COUNT(*) as total, " +
|
||||||
"COUNT(CASE WHEN request_status = 'SUCCESS' THEN 1 END) as successCount, " +
|
"COUNT(CASE WHEN request_status = 'SUCCESS' THEN 1 END) as successCount, " +
|
||||||
|
|||||||
@ -0,0 +1,80 @@
|
|||||||
|
package com.gcsc.connection.service.controller;
|
||||||
|
|
||||||
|
import com.gcsc.connection.common.dto.ApiResponse;
|
||||||
|
import com.gcsc.connection.service.dto.ApiDomainResponse;
|
||||||
|
import com.gcsc.connection.service.dto.SaveApiDomainRequest;
|
||||||
|
import com.gcsc.connection.service.entity.SnpApiDomain;
|
||||||
|
import com.gcsc.connection.service.repository.SnpApiDomainRepository;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API 도메인 관리 API
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/domains")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ApiDomainController {
|
||||||
|
|
||||||
|
private final SnpApiDomainRepository snpApiDomainRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전체 도메인 목록 조회 (sortOrder 오름차순)
|
||||||
|
*/
|
||||||
|
@GetMapping
|
||||||
|
public ResponseEntity<ApiResponse<List<ApiDomainResponse>>> getDomains() {
|
||||||
|
List<ApiDomainResponse> domains = snpApiDomainRepository.findAllByOrderBySortOrderAscDomainNameAsc()
|
||||||
|
.stream()
|
||||||
|
.map(ApiDomainResponse::from)
|
||||||
|
.toList();
|
||||||
|
return ResponseEntity.ok(ApiResponse.ok(domains));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 도메인 생성
|
||||||
|
*/
|
||||||
|
@PostMapping
|
||||||
|
public ResponseEntity<ApiResponse<ApiDomainResponse>> createDomain(
|
||||||
|
@RequestBody SaveApiDomainRequest request) {
|
||||||
|
SnpApiDomain domain = SnpApiDomain.builder()
|
||||||
|
.domainName(request.domainName())
|
||||||
|
.iconPath(request.iconPath())
|
||||||
|
.sortOrder(request.sortOrder())
|
||||||
|
.build();
|
||||||
|
SnpApiDomain saved = snpApiDomainRepository.save(domain);
|
||||||
|
return ResponseEntity.ok(ApiResponse.ok(ApiDomainResponse.from(saved)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 도메인 수정
|
||||||
|
*/
|
||||||
|
@PutMapping("/{domainId}")
|
||||||
|
public ResponseEntity<ApiResponse<ApiDomainResponse>> updateDomain(
|
||||||
|
@PathVariable Long domainId,
|
||||||
|
@RequestBody SaveApiDomainRequest request) {
|
||||||
|
SnpApiDomain domain = snpApiDomainRepository.findById(domainId)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("도메인을 찾을 수 없습니다: " + domainId));
|
||||||
|
domain.update(request.domainName(), request.iconPath(), request.sortOrder());
|
||||||
|
SnpApiDomain saved = snpApiDomainRepository.save(domain);
|
||||||
|
return ResponseEntity.ok(ApiResponse.ok(ApiDomainResponse.from(saved)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 도메인 삭제
|
||||||
|
*/
|
||||||
|
@DeleteMapping("/{domainId}")
|
||||||
|
public ResponseEntity<ApiResponse<Void>> deleteDomain(@PathVariable Long domainId) {
|
||||||
|
snpApiDomainRepository.deleteById(domainId);
|
||||||
|
return ResponseEntity.ok(ApiResponse.ok(null));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,15 +1,22 @@
|
|||||||
package com.gcsc.connection.service.controller;
|
package com.gcsc.connection.service.controller;
|
||||||
|
|
||||||
import com.gcsc.connection.common.dto.ApiResponse;
|
import com.gcsc.connection.common.dto.ApiResponse;
|
||||||
|
import com.gcsc.connection.service.dto.ApiDetailResponse;
|
||||||
|
import com.gcsc.connection.service.dto.ApiParamResponse;
|
||||||
|
import com.gcsc.connection.service.dto.ApiSpecResponse;
|
||||||
import com.gcsc.connection.service.dto.CreateServiceApiRequest;
|
import com.gcsc.connection.service.dto.CreateServiceApiRequest;
|
||||||
import com.gcsc.connection.service.dto.CreateServiceRequest;
|
import com.gcsc.connection.service.dto.CreateServiceRequest;
|
||||||
|
import com.gcsc.connection.service.dto.SaveApiParamRequest;
|
||||||
|
import com.gcsc.connection.service.dto.SaveApiSpecRequest;
|
||||||
import com.gcsc.connection.service.dto.ServiceApiResponse;
|
import com.gcsc.connection.service.dto.ServiceApiResponse;
|
||||||
import com.gcsc.connection.service.dto.ServiceResponse;
|
import com.gcsc.connection.service.dto.ServiceResponse;
|
||||||
|
import com.gcsc.connection.service.dto.UpdateServiceApiRequest;
|
||||||
import com.gcsc.connection.service.dto.UpdateServiceRequest;
|
import com.gcsc.connection.service.dto.UpdateServiceRequest;
|
||||||
import com.gcsc.connection.service.service.ServiceManagementService;
|
import com.gcsc.connection.service.service.ServiceManagementService;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
@ -39,6 +46,15 @@ public class ServiceController {
|
|||||||
return ResponseEntity.ok(ApiResponse.ok(services));
|
return ResponseEntity.ok(ApiResponse.ok(services));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 서비스 단건 조회
|
||||||
|
*/
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public ResponseEntity<ApiResponse<ServiceResponse>> getService(@PathVariable Long id) {
|
||||||
|
ServiceResponse service = serviceManagementService.getService(id);
|
||||||
|
return ResponseEntity.ok(ApiResponse.ok(service));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 서비스 생성
|
* 서비스 생성
|
||||||
*/
|
*/
|
||||||
@ -60,6 +76,15 @@ public class ServiceController {
|
|||||||
return ResponseEntity.ok(ApiResponse.ok(service));
|
return ResponseEntity.ok(ApiResponse.ok(service));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 서비스 삭제 (비활성화)
|
||||||
|
*/
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
public ResponseEntity<ApiResponse<Void>> deleteService(@PathVariable Long id) {
|
||||||
|
serviceManagementService.deleteService(id);
|
||||||
|
return ResponseEntity.ok(ApiResponse.ok(null));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 서비스 API 목록 조회
|
* 서비스 API 목록 조회
|
||||||
*/
|
*/
|
||||||
@ -80,4 +105,57 @@ public class ServiceController {
|
|||||||
ServiceApiResponse api = serviceManagementService.createServiceApi(id, request);
|
ServiceApiResponse api = serviceManagementService.createServiceApi(id, request);
|
||||||
return ResponseEntity.ok(ApiResponse.ok(api));
|
return ResponseEntity.ok(ApiResponse.ok(api));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 서비스 API 수정
|
||||||
|
*/
|
||||||
|
@PutMapping("/{serviceId}/apis/{apiId}")
|
||||||
|
public ResponseEntity<ApiResponse<ServiceApiResponse>> updateServiceApi(
|
||||||
|
@PathVariable Long serviceId, @PathVariable Long apiId,
|
||||||
|
@RequestBody @Valid UpdateServiceApiRequest request) {
|
||||||
|
ServiceApiResponse api = serviceManagementService.updateServiceApi(serviceId, apiId, request);
|
||||||
|
return ResponseEntity.ok(ApiResponse.ok(api));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 서비스 API 삭제 (비활성화)
|
||||||
|
*/
|
||||||
|
@DeleteMapping("/{serviceId}/apis/{apiId}")
|
||||||
|
public ResponseEntity<ApiResponse<Void>> deleteServiceApi(
|
||||||
|
@PathVariable Long serviceId, @PathVariable Long apiId) {
|
||||||
|
serviceManagementService.deleteServiceApi(serviceId, apiId);
|
||||||
|
return ResponseEntity.ok(ApiResponse.ok(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API 상세 명세 조회 (스펙 + 파라미터)
|
||||||
|
*/
|
||||||
|
@GetMapping("/{serviceId}/apis/{apiId}/spec")
|
||||||
|
public ResponseEntity<ApiResponse<ApiDetailResponse>> getApiDetail(
|
||||||
|
@PathVariable Long serviceId, @PathVariable Long apiId) {
|
||||||
|
ApiDetailResponse detail = serviceManagementService.getApiDetail(serviceId, apiId);
|
||||||
|
return ResponseEntity.ok(ApiResponse.ok(detail));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API 명세 저장 (upsert)
|
||||||
|
*/
|
||||||
|
@PutMapping("/{serviceId}/apis/{apiId}/spec")
|
||||||
|
public ResponseEntity<ApiResponse<ApiSpecResponse>> saveApiSpec(
|
||||||
|
@PathVariable Long serviceId, @PathVariable Long apiId,
|
||||||
|
@RequestBody @Valid SaveApiSpecRequest request) {
|
||||||
|
ApiSpecResponse spec = serviceManagementService.saveApiSpec(serviceId, apiId, request);
|
||||||
|
return ResponseEntity.ok(ApiResponse.ok(spec));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API 파라미터 전체 교체
|
||||||
|
*/
|
||||||
|
@PutMapping("/{serviceId}/apis/{apiId}/params")
|
||||||
|
public ResponseEntity<ApiResponse<List<ApiParamResponse>>> saveApiParams(
|
||||||
|
@PathVariable Long serviceId, @PathVariable Long apiId,
|
||||||
|
@RequestBody List<SaveApiParamRequest> requests) {
|
||||||
|
List<ApiParamResponse> params = serviceManagementService.saveApiParams(serviceId, apiId, requests);
|
||||||
|
return ResponseEntity.ok(ApiResponse.ok(params));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,11 @@
|
|||||||
|
package com.gcsc.connection.service.dto;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public record ApiDetailResponse(
|
||||||
|
ServiceApiResponse api,
|
||||||
|
ApiSpecResponse spec,
|
||||||
|
List<ApiParamResponse> requestParams,
|
||||||
|
List<ApiParamResponse> responseParams
|
||||||
|
) {
|
||||||
|
}
|
||||||
@ -0,0 +1,26 @@
|
|||||||
|
package com.gcsc.connection.service.dto;
|
||||||
|
|
||||||
|
import com.gcsc.connection.service.entity.SnpApiDomain;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
public record ApiDomainResponse(
|
||||||
|
Long domainId,
|
||||||
|
String domainName,
|
||||||
|
String iconPath,
|
||||||
|
Integer sortOrder,
|
||||||
|
LocalDateTime createdAt,
|
||||||
|
LocalDateTime updatedAt
|
||||||
|
) {
|
||||||
|
|
||||||
|
public static ApiDomainResponse from(SnpApiDomain domain) {
|
||||||
|
return new ApiDomainResponse(
|
||||||
|
domain.getDomainId(),
|
||||||
|
domain.getDomainName(),
|
||||||
|
domain.getIconPath(),
|
||||||
|
domain.getSortOrder(),
|
||||||
|
domain.getCreatedAt(),
|
||||||
|
domain.getUpdatedAt()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,32 @@
|
|||||||
|
package com.gcsc.connection.service.dto;
|
||||||
|
|
||||||
|
import com.gcsc.connection.service.entity.SnpServiceApiParam;
|
||||||
|
|
||||||
|
public record ApiParamResponse(
|
||||||
|
Long paramId,
|
||||||
|
Long apiId,
|
||||||
|
String paramType,
|
||||||
|
String paramName,
|
||||||
|
String paramMeaning,
|
||||||
|
String paramDescription,
|
||||||
|
Boolean required,
|
||||||
|
String defaultValue,
|
||||||
|
String inputType,
|
||||||
|
Integer sortOrder
|
||||||
|
) {
|
||||||
|
|
||||||
|
public static ApiParamResponse from(SnpServiceApiParam p) {
|
||||||
|
return new ApiParamResponse(
|
||||||
|
p.getParamId(),
|
||||||
|
p.getApi().getApiId(),
|
||||||
|
p.getParamType(),
|
||||||
|
p.getParamName(),
|
||||||
|
p.getParamMeaning(),
|
||||||
|
p.getParamDescription(),
|
||||||
|
p.getRequired(),
|
||||||
|
p.getDefaultValue(),
|
||||||
|
p.getInputType(),
|
||||||
|
p.getSortOrder()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,42 @@
|
|||||||
|
package com.gcsc.connection.service.dto;
|
||||||
|
|
||||||
|
import com.gcsc.connection.service.entity.SnpServiceApiSpec;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
public record ApiSpecResponse(
|
||||||
|
Long specId,
|
||||||
|
Long apiId,
|
||||||
|
String sampleUrl,
|
||||||
|
String sampleCode,
|
||||||
|
String requestBodyExample,
|
||||||
|
String responseBodyExample,
|
||||||
|
Boolean authRequired,
|
||||||
|
String authType,
|
||||||
|
Boolean deprecated,
|
||||||
|
String dataFormat,
|
||||||
|
String referenceUrl,
|
||||||
|
String note,
|
||||||
|
LocalDateTime createdAt,
|
||||||
|
LocalDateTime updatedAt
|
||||||
|
) {
|
||||||
|
|
||||||
|
public static ApiSpecResponse from(SnpServiceApiSpec s) {
|
||||||
|
return new ApiSpecResponse(
|
||||||
|
s.getSpecId(),
|
||||||
|
s.getApi().getApiId(),
|
||||||
|
s.getSampleUrl(),
|
||||||
|
s.getSampleCode(),
|
||||||
|
s.getRequestBodyExample(),
|
||||||
|
s.getResponseBodyExample(),
|
||||||
|
s.getAuthRequired(),
|
||||||
|
s.getAuthType(),
|
||||||
|
s.getDeprecated(),
|
||||||
|
s.getDataFormat(),
|
||||||
|
s.getReferenceUrl(),
|
||||||
|
s.getNote(),
|
||||||
|
s.getCreatedAt(),
|
||||||
|
s.getUpdatedAt()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
package com.gcsc.connection.service.dto;
|
||||||
|
|
||||||
|
public record SaveApiDomainRequest(
|
||||||
|
String domainName,
|
||||||
|
String iconPath,
|
||||||
|
Integer sortOrder
|
||||||
|
) {
|
||||||
|
}
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
package com.gcsc.connection.service.dto;
|
||||||
|
|
||||||
|
public record SaveApiParamRequest(
|
||||||
|
String paramType,
|
||||||
|
String paramName,
|
||||||
|
String paramMeaning,
|
||||||
|
String paramDescription,
|
||||||
|
Boolean required,
|
||||||
|
String defaultValue,
|
||||||
|
String inputType,
|
||||||
|
Integer sortOrder
|
||||||
|
) {
|
||||||
|
}
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
package com.gcsc.connection.service.dto;
|
||||||
|
|
||||||
|
public record SaveApiSpecRequest(
|
||||||
|
String sampleUrl,
|
||||||
|
String sampleCode,
|
||||||
|
String requestBodyExample,
|
||||||
|
String responseBodyExample,
|
||||||
|
Boolean authRequired,
|
||||||
|
String authType,
|
||||||
|
Boolean deprecated,
|
||||||
|
String dataFormat,
|
||||||
|
String referenceUrl,
|
||||||
|
String note
|
||||||
|
) {
|
||||||
|
}
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
package com.gcsc.connection.service.dto;
|
||||||
|
|
||||||
|
public record UpdateServiceApiRequest(
|
||||||
|
String apiPath,
|
||||||
|
String apiMethod,
|
||||||
|
String apiName,
|
||||||
|
String apiDomain,
|
||||||
|
String apiSection,
|
||||||
|
String description,
|
||||||
|
Boolean isActive
|
||||||
|
) {
|
||||||
|
}
|
||||||
@ -0,0 +1,46 @@
|
|||||||
|
package com.gcsc.connection.service.entity;
|
||||||
|
|
||||||
|
import com.gcsc.connection.common.entity.BaseEntity;
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.GeneratedValue;
|
||||||
|
import jakarta.persistence.GenerationType;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@NoArgsConstructor(access = lombok.AccessLevel.PROTECTED)
|
||||||
|
@Entity
|
||||||
|
@Table(name = "snp_api_domain", schema = "common")
|
||||||
|
public class SnpApiDomain extends BaseEntity {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
@Column(name = "domain_id")
|
||||||
|
private Long domainId;
|
||||||
|
|
||||||
|
@Column(name = "domain_name", length = 100, unique = true, nullable = false)
|
||||||
|
private String domainName;
|
||||||
|
|
||||||
|
@Column(name = "icon_path", columnDefinition = "TEXT")
|
||||||
|
private String iconPath;
|
||||||
|
|
||||||
|
@Column(name = "sort_order", nullable = false)
|
||||||
|
private Integer sortOrder = 0;
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
public SnpApiDomain(String domainName, String iconPath, Integer sortOrder) {
|
||||||
|
this.domainName = domainName;
|
||||||
|
this.iconPath = iconPath;
|
||||||
|
this.sortOrder = sortOrder != null ? sortOrder : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void update(String domainName, String iconPath, Integer sortOrder) {
|
||||||
|
if (domainName != null) this.domainName = domainName;
|
||||||
|
if (iconPath != null) this.iconPath = iconPath;
|
||||||
|
if (sortOrder != null) this.sortOrder = sortOrder;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,84 @@
|
|||||||
|
package com.gcsc.connection.service.entity;
|
||||||
|
|
||||||
|
import com.gcsc.connection.common.entity.BaseEntity;
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.FetchType;
|
||||||
|
import jakarta.persistence.GeneratedValue;
|
||||||
|
import jakarta.persistence.GenerationType;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.JoinColumn;
|
||||||
|
import jakarta.persistence.ManyToOne;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import lombok.AccessLevel;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
||||||
|
@Entity
|
||||||
|
@Table(name = "snp_service_api_param", schema = "common")
|
||||||
|
public class SnpServiceApiParam extends BaseEntity {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
@Column(name = "param_id")
|
||||||
|
private Long paramId;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "api_id", nullable = false)
|
||||||
|
private SnpServiceApi api;
|
||||||
|
|
||||||
|
@Column(name = "param_type", length = 10, nullable = false)
|
||||||
|
private String paramType;
|
||||||
|
|
||||||
|
@Column(name = "param_name", length = 100, nullable = false)
|
||||||
|
private String paramName;
|
||||||
|
|
||||||
|
@Column(name = "param_meaning", length = 200)
|
||||||
|
private String paramMeaning;
|
||||||
|
|
||||||
|
@Column(name = "param_description", columnDefinition = "TEXT")
|
||||||
|
private String paramDescription;
|
||||||
|
|
||||||
|
@Column(name = "required", nullable = false)
|
||||||
|
private Boolean required = false;
|
||||||
|
|
||||||
|
@Column(name = "default_value", length = 200)
|
||||||
|
private String defaultValue;
|
||||||
|
|
||||||
|
@Column(name = "input_type", length = 20)
|
||||||
|
private String inputType;
|
||||||
|
|
||||||
|
@Column(name = "sort_order", nullable = false)
|
||||||
|
private Integer sortOrder = 0;
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
public SnpServiceApiParam(SnpServiceApi api, String paramType, String paramName,
|
||||||
|
String paramMeaning, String paramDescription, Boolean required,
|
||||||
|
String defaultValue, String inputType, Integer sortOrder) {
|
||||||
|
this.api = api;
|
||||||
|
this.paramType = paramType;
|
||||||
|
this.paramName = paramName;
|
||||||
|
this.paramMeaning = paramMeaning;
|
||||||
|
this.paramDescription = paramDescription;
|
||||||
|
this.required = required != null ? required : false;
|
||||||
|
this.defaultValue = defaultValue;
|
||||||
|
this.inputType = inputType;
|
||||||
|
this.sortOrder = sortOrder != null ? sortOrder : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void update(String paramType, String paramName, String paramMeaning,
|
||||||
|
String paramDescription, Boolean required, String defaultValue,
|
||||||
|
String inputType, Integer sortOrder) {
|
||||||
|
if (paramType != null) this.paramType = paramType;
|
||||||
|
if (paramName != null) this.paramName = paramName;
|
||||||
|
if (paramMeaning != null) this.paramMeaning = paramMeaning;
|
||||||
|
if (paramDescription != null) this.paramDescription = paramDescription;
|
||||||
|
if (required != null) this.required = required;
|
||||||
|
if (defaultValue != null) this.defaultValue = defaultValue;
|
||||||
|
if (inputType != null) this.inputType = inputType;
|
||||||
|
if (sortOrder != null) this.sortOrder = sortOrder;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,95 @@
|
|||||||
|
package com.gcsc.connection.service.entity;
|
||||||
|
|
||||||
|
import com.gcsc.connection.common.entity.BaseEntity;
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.FetchType;
|
||||||
|
import jakarta.persistence.GeneratedValue;
|
||||||
|
import jakarta.persistence.GenerationType;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.JoinColumn;
|
||||||
|
import jakarta.persistence.OneToOne;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import lombok.AccessLevel;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
||||||
|
@Entity
|
||||||
|
@Table(name = "snp_service_api_spec", schema = "common")
|
||||||
|
public class SnpServiceApiSpec extends BaseEntity {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
@Column(name = "spec_id")
|
||||||
|
private Long specId;
|
||||||
|
|
||||||
|
@OneToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "api_id", nullable = false, unique = true)
|
||||||
|
private SnpServiceApi api;
|
||||||
|
|
||||||
|
@Column(name = "sample_url", length = 1000)
|
||||||
|
private String sampleUrl;
|
||||||
|
|
||||||
|
@Column(name = "sample_code", columnDefinition = "TEXT")
|
||||||
|
private String sampleCode;
|
||||||
|
|
||||||
|
@Column(name = "request_body_example", columnDefinition = "TEXT")
|
||||||
|
private String requestBodyExample;
|
||||||
|
|
||||||
|
@Column(name = "response_body_example", columnDefinition = "TEXT")
|
||||||
|
private String responseBodyExample;
|
||||||
|
|
||||||
|
@Column(name = "auth_required", nullable = false)
|
||||||
|
private Boolean authRequired = false;
|
||||||
|
|
||||||
|
@Column(name = "auth_type", length = 20)
|
||||||
|
private String authType;
|
||||||
|
|
||||||
|
@Column(name = "deprecated", nullable = false)
|
||||||
|
private Boolean deprecated = false;
|
||||||
|
|
||||||
|
@Column(name = "data_format", length = 100)
|
||||||
|
private String dataFormat;
|
||||||
|
|
||||||
|
@Column(name = "reference_url", length = 500)
|
||||||
|
private String referenceUrl;
|
||||||
|
|
||||||
|
@Column(name = "note", columnDefinition = "TEXT")
|
||||||
|
private String note;
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
public SnpServiceApiSpec(SnpServiceApi api, String sampleUrl, String sampleCode,
|
||||||
|
String requestBodyExample, String responseBodyExample,
|
||||||
|
Boolean authRequired, String authType, Boolean deprecated,
|
||||||
|
String dataFormat, String referenceUrl, String note) {
|
||||||
|
this.api = api;
|
||||||
|
this.sampleUrl = sampleUrl;
|
||||||
|
this.sampleCode = sampleCode;
|
||||||
|
this.requestBodyExample = requestBodyExample;
|
||||||
|
this.responseBodyExample = responseBodyExample;
|
||||||
|
this.authRequired = authRequired != null ? authRequired : false;
|
||||||
|
this.authType = authType;
|
||||||
|
this.deprecated = deprecated != null ? deprecated : false;
|
||||||
|
this.dataFormat = dataFormat;
|
||||||
|
this.referenceUrl = referenceUrl;
|
||||||
|
this.note = note;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void update(String sampleUrl, String sampleCode, String requestBodyExample,
|
||||||
|
String responseBodyExample, Boolean authRequired, String authType,
|
||||||
|
Boolean deprecated, String dataFormat, String referenceUrl, String note) {
|
||||||
|
if (sampleUrl != null) this.sampleUrl = sampleUrl;
|
||||||
|
if (sampleCode != null) this.sampleCode = sampleCode;
|
||||||
|
if (requestBodyExample != null) this.requestBodyExample = requestBodyExample;
|
||||||
|
if (responseBodyExample != null) this.responseBodyExample = responseBodyExample;
|
||||||
|
if (authRequired != null) this.authRequired = authRequired;
|
||||||
|
if (authType != null) this.authType = authType;
|
||||||
|
if (deprecated != null) this.deprecated = deprecated;
|
||||||
|
if (dataFormat != null) this.dataFormat = dataFormat;
|
||||||
|
if (referenceUrl != null) this.referenceUrl = referenceUrl;
|
||||||
|
if (note != null) this.note = note;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
package com.gcsc.connection.service.repository;
|
||||||
|
|
||||||
|
import com.gcsc.connection.service.entity.SnpApiDomain;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public interface SnpApiDomainRepository extends JpaRepository<SnpApiDomain, Long> {
|
||||||
|
|
||||||
|
Optional<SnpApiDomain> findByDomainName(String domainName);
|
||||||
|
|
||||||
|
List<SnpApiDomain> findAllByOrderBySortOrderAscDomainNameAsc();
|
||||||
|
}
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
package com.gcsc.connection.service.repository;
|
||||||
|
|
||||||
|
import com.gcsc.connection.service.entity.SnpServiceApiParam;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public interface SnpServiceApiParamRepository extends JpaRepository<SnpServiceApiParam, Long> {
|
||||||
|
|
||||||
|
List<SnpServiceApiParam> findByApiApiIdOrderBySortOrder(Long apiId);
|
||||||
|
|
||||||
|
List<SnpServiceApiParam> findByApiApiIdAndParamTypeOrderBySortOrder(Long apiId, String paramType);
|
||||||
|
|
||||||
|
void deleteByApiApiId(Long apiId);
|
||||||
|
}
|
||||||
@ -15,4 +15,8 @@ public interface SnpServiceApiRepository extends JpaRepository<SnpServiceApi, Lo
|
|||||||
|
|
||||||
Optional<SnpServiceApi> findByServiceServiceIdAndApiPathAndApiMethod(
|
Optional<SnpServiceApi> findByServiceServiceIdAndApiPathAndApiMethod(
|
||||||
Long serviceId, String apiPath, String apiMethod);
|
Long serviceId, String apiPath, String apiMethod);
|
||||||
|
|
||||||
|
List<SnpServiceApi> findByServiceServiceIdAndIsActiveTrue(Long serviceId);
|
||||||
|
|
||||||
|
List<SnpServiceApi> findTop10ByIsActiveTrueOrderByCreatedAtDesc();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,13 @@
|
|||||||
|
package com.gcsc.connection.service.repository;
|
||||||
|
|
||||||
|
import com.gcsc.connection.service.entity.SnpServiceApiSpec;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public interface SnpServiceApiSpecRepository extends JpaRepository<SnpServiceApiSpec, Long> {
|
||||||
|
|
||||||
|
Optional<SnpServiceApiSpec> findByApiApiId(Long apiId);
|
||||||
|
|
||||||
|
void deleteByApiApiId(Long apiId);
|
||||||
|
}
|
||||||
@ -2,14 +2,24 @@ package com.gcsc.connection.service.service;
|
|||||||
|
|
||||||
import com.gcsc.connection.common.exception.BusinessException;
|
import com.gcsc.connection.common.exception.BusinessException;
|
||||||
import com.gcsc.connection.common.exception.ErrorCode;
|
import com.gcsc.connection.common.exception.ErrorCode;
|
||||||
|
import com.gcsc.connection.service.dto.ApiDetailResponse;
|
||||||
|
import com.gcsc.connection.service.dto.ApiParamResponse;
|
||||||
|
import com.gcsc.connection.service.dto.ApiSpecResponse;
|
||||||
import com.gcsc.connection.service.dto.CreateServiceApiRequest;
|
import com.gcsc.connection.service.dto.CreateServiceApiRequest;
|
||||||
import com.gcsc.connection.service.dto.CreateServiceRequest;
|
import com.gcsc.connection.service.dto.CreateServiceRequest;
|
||||||
|
import com.gcsc.connection.service.dto.SaveApiParamRequest;
|
||||||
|
import com.gcsc.connection.service.dto.SaveApiSpecRequest;
|
||||||
import com.gcsc.connection.service.dto.ServiceApiResponse;
|
import com.gcsc.connection.service.dto.ServiceApiResponse;
|
||||||
import com.gcsc.connection.service.dto.ServiceResponse;
|
import com.gcsc.connection.service.dto.ServiceResponse;
|
||||||
|
import com.gcsc.connection.service.dto.UpdateServiceApiRequest;
|
||||||
import com.gcsc.connection.service.dto.UpdateServiceRequest;
|
import com.gcsc.connection.service.dto.UpdateServiceRequest;
|
||||||
import com.gcsc.connection.service.entity.SnpService;
|
import com.gcsc.connection.service.entity.SnpService;
|
||||||
import com.gcsc.connection.service.entity.SnpServiceApi;
|
import com.gcsc.connection.service.entity.SnpServiceApi;
|
||||||
|
import com.gcsc.connection.service.entity.SnpServiceApiParam;
|
||||||
|
import com.gcsc.connection.service.entity.SnpServiceApiSpec;
|
||||||
|
import com.gcsc.connection.service.repository.SnpServiceApiParamRepository;
|
||||||
import com.gcsc.connection.service.repository.SnpServiceApiRepository;
|
import com.gcsc.connection.service.repository.SnpServiceApiRepository;
|
||||||
|
import com.gcsc.connection.service.repository.SnpServiceApiSpecRepository;
|
||||||
import com.gcsc.connection.service.repository.SnpServiceRepository;
|
import com.gcsc.connection.service.repository.SnpServiceRepository;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
@ -25,6 +35,8 @@ public class ServiceManagementService {
|
|||||||
|
|
||||||
private final SnpServiceRepository snpServiceRepository;
|
private final SnpServiceRepository snpServiceRepository;
|
||||||
private final SnpServiceApiRepository snpServiceApiRepository;
|
private final SnpServiceApiRepository snpServiceApiRepository;
|
||||||
|
private final SnpServiceApiSpecRepository snpServiceApiSpecRepository;
|
||||||
|
private final SnpServiceApiParamRepository snpServiceApiParamRepository;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 전체 서비스 목록 조회
|
* 전체 서비스 목록 조회
|
||||||
@ -89,6 +101,163 @@ public class ServiceManagementService {
|
|||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API 상세 명세 조회
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public ApiDetailResponse getApiDetail(Long serviceId, Long apiId) {
|
||||||
|
snpServiceRepository.findById(serviceId)
|
||||||
|
.orElseThrow(() -> new BusinessException(ErrorCode.SERVICE_NOT_FOUND));
|
||||||
|
|
||||||
|
SnpServiceApi api = snpServiceApiRepository.findById(apiId)
|
||||||
|
.filter(a -> a.getService().getServiceId().equals(serviceId))
|
||||||
|
.orElseThrow(() -> new BusinessException(ErrorCode.SERVICE_API_NOT_FOUND));
|
||||||
|
|
||||||
|
SnpServiceApiSpec spec = snpServiceApiSpecRepository.findByApiApiId(apiId).orElse(null);
|
||||||
|
List<SnpServiceApiParam> requestParams = snpServiceApiParamRepository
|
||||||
|
.findByApiApiIdAndParamTypeOrderBySortOrder(apiId, "REQUEST");
|
||||||
|
List<SnpServiceApiParam> responseParams = snpServiceApiParamRepository
|
||||||
|
.findByApiApiIdAndParamTypeOrderBySortOrder(apiId, "RESPONSE");
|
||||||
|
|
||||||
|
return new ApiDetailResponse(
|
||||||
|
ServiceApiResponse.from(api),
|
||||||
|
spec != null ? ApiSpecResponse.from(spec) : null,
|
||||||
|
requestParams.stream().map(ApiParamResponse::from).toList(),
|
||||||
|
responseParams.stream().map(ApiParamResponse::from).toList()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 서비스 단건 조회
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public ServiceResponse getService(Long id) {
|
||||||
|
SnpService service = snpServiceRepository.findById(id)
|
||||||
|
.orElseThrow(() -> new BusinessException(ErrorCode.SERVICE_NOT_FOUND));
|
||||||
|
return ServiceResponse.from(service);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 서비스 삭제 (soft delete)
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void deleteService(Long id) {
|
||||||
|
SnpService service = snpServiceRepository.findById(id)
|
||||||
|
.orElseThrow(() -> new BusinessException(ErrorCode.SERVICE_NOT_FOUND));
|
||||||
|
service.update(null, null, null, null, null, false);
|
||||||
|
log.info("서비스 비활성화 완료: {}", service.getServiceCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 서비스 API 수정
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public ServiceApiResponse updateServiceApi(Long serviceId, Long apiId, UpdateServiceApiRequest request) {
|
||||||
|
snpServiceRepository.findById(serviceId)
|
||||||
|
.orElseThrow(() -> new BusinessException(ErrorCode.SERVICE_NOT_FOUND));
|
||||||
|
|
||||||
|
SnpServiceApi api = snpServiceApiRepository.findById(apiId)
|
||||||
|
.filter(a -> a.getService().getServiceId().equals(serviceId))
|
||||||
|
.orElseThrow(() -> new BusinessException(ErrorCode.SERVICE_API_NOT_FOUND));
|
||||||
|
|
||||||
|
api.update(request.apiPath(), request.apiMethod(), request.apiName(),
|
||||||
|
request.apiDomain(), request.apiSection(), request.description(), request.isActive());
|
||||||
|
|
||||||
|
log.info("서비스 API 수정 완료: {} {}", api.getApiMethod(), api.getApiPath());
|
||||||
|
return ServiceApiResponse.from(api);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 서비스 API 삭제 (soft delete)
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void deleteServiceApi(Long serviceId, Long apiId) {
|
||||||
|
snpServiceRepository.findById(serviceId)
|
||||||
|
.orElseThrow(() -> new BusinessException(ErrorCode.SERVICE_NOT_FOUND));
|
||||||
|
|
||||||
|
SnpServiceApi api = snpServiceApiRepository.findById(apiId)
|
||||||
|
.filter(a -> a.getService().getServiceId().equals(serviceId))
|
||||||
|
.orElseThrow(() -> new BusinessException(ErrorCode.SERVICE_API_NOT_FOUND));
|
||||||
|
|
||||||
|
api.update(null, null, null, null, null, null, false);
|
||||||
|
log.info("서비스 API 비활성화 완료: {} {}", api.getApiMethod(), api.getApiPath());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API 명세 저장 (upsert)
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public ApiSpecResponse saveApiSpec(Long serviceId, Long apiId, SaveApiSpecRequest request) {
|
||||||
|
snpServiceRepository.findById(serviceId)
|
||||||
|
.orElseThrow(() -> new BusinessException(ErrorCode.SERVICE_NOT_FOUND));
|
||||||
|
|
||||||
|
SnpServiceApi api = snpServiceApiRepository.findById(apiId)
|
||||||
|
.filter(a -> a.getService().getServiceId().equals(serviceId))
|
||||||
|
.orElseThrow(() -> new BusinessException(ErrorCode.SERVICE_API_NOT_FOUND));
|
||||||
|
|
||||||
|
SnpServiceApiSpec spec = snpServiceApiSpecRepository.findByApiApiId(apiId)
|
||||||
|
.orElse(null);
|
||||||
|
|
||||||
|
if (spec != null) {
|
||||||
|
spec.update(request.sampleUrl(), request.sampleCode(),
|
||||||
|
request.requestBodyExample(), request.responseBodyExample(),
|
||||||
|
request.authRequired(), request.authType(), request.deprecated(),
|
||||||
|
request.dataFormat(), request.referenceUrl(), request.note());
|
||||||
|
} else {
|
||||||
|
spec = SnpServiceApiSpec.builder()
|
||||||
|
.api(api)
|
||||||
|
.sampleUrl(request.sampleUrl())
|
||||||
|
.sampleCode(request.sampleCode())
|
||||||
|
.requestBodyExample(request.requestBodyExample())
|
||||||
|
.responseBodyExample(request.responseBodyExample())
|
||||||
|
.authRequired(request.authRequired())
|
||||||
|
.authType(request.authType())
|
||||||
|
.deprecated(request.deprecated())
|
||||||
|
.dataFormat(request.dataFormat())
|
||||||
|
.referenceUrl(request.referenceUrl())
|
||||||
|
.note(request.note())
|
||||||
|
.build();
|
||||||
|
snpServiceApiSpecRepository.save(spec);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("API 명세 저장 완료: apiId={}", apiId);
|
||||||
|
return ApiSpecResponse.from(spec);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API 파라미터 전체 교체
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public List<ApiParamResponse> saveApiParams(Long serviceId, Long apiId, List<SaveApiParamRequest> requests) {
|
||||||
|
snpServiceRepository.findById(serviceId)
|
||||||
|
.orElseThrow(() -> new BusinessException(ErrorCode.SERVICE_NOT_FOUND));
|
||||||
|
|
||||||
|
SnpServiceApi api = snpServiceApiRepository.findById(apiId)
|
||||||
|
.filter(a -> a.getService().getServiceId().equals(serviceId))
|
||||||
|
.orElseThrow(() -> new BusinessException(ErrorCode.SERVICE_API_NOT_FOUND));
|
||||||
|
|
||||||
|
snpServiceApiParamRepository.deleteByApiApiId(apiId);
|
||||||
|
snpServiceApiParamRepository.flush();
|
||||||
|
|
||||||
|
List<SnpServiceApiParam> params = requests.stream()
|
||||||
|
.map(req -> SnpServiceApiParam.builder()
|
||||||
|
.api(api)
|
||||||
|
.paramType(req.paramType())
|
||||||
|
.paramName(req.paramName())
|
||||||
|
.paramMeaning(req.paramMeaning())
|
||||||
|
.paramDescription(req.paramDescription())
|
||||||
|
.required(req.required())
|
||||||
|
.defaultValue(req.defaultValue())
|
||||||
|
.inputType(req.inputType())
|
||||||
|
.sortOrder(req.sortOrder())
|
||||||
|
.build())
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
List<SnpServiceApiParam> saved = snpServiceApiParamRepository.saveAll(params);
|
||||||
|
log.info("API 파라미터 저장 완료: apiId={}, count={}", apiId, saved.size());
|
||||||
|
return saved.stream().map(ApiParamResponse::from).toList();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 서비스 API 생성
|
* 서비스 API 생성
|
||||||
*/
|
*/
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user