generated from gc/template-java-maven
feat(api-hub): S&P API HUB SPA 구현 (#40)
- API Hub 대시보드 (배너, 인기 API, 최신 API, 서비스 카드) - 서비스 트리 사이드바 레이아웃 (서비스 > 도메인 > API) - 서비스별 API 목록 페이지 (도메인별 그룹) - API 상세 명세 페이지 - 백엔드 카탈로그/최신 API 조회 엔드포인트 - 메인 사이드바에 API Hub 링크 추가 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
부모
cf6f2c5146
커밋
6f2627271f
@ -18,6 +18,10 @@ 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 NotFoundPage from './pages/NotFoundPage';
|
import NotFoundPage from './pages/NotFoundPage';
|
||||||
import RoleGuard from './components/RoleGuard';
|
import RoleGuard from './components/RoleGuard';
|
||||||
|
|
||||||
@ -49,6 +53,11 @@ const App = () => {
|
|||||||
<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/services/:serviceId" element={<ApiHubServicePage />} />
|
||||||
|
<Route path="/api-hub/services/:serviceId/apis/:apiId" element={<ApiHubApiDetailPage />} />
|
||||||
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
|||||||
290
frontend/src/layouts/ApiHubLayout.tsx
Normal file
290
frontend/src/layouts/ApiHubLayout.tsx
Normal file
@ -0,0 +1,290 @@
|
|||||||
|
import { useState, useEffect } 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 METHOD_BADGE_CLASS: 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 getMethodBadgeClass = (method: string): string =>
|
||||||
|
METHOD_BADGE_CLASS[method.toUpperCase()] ??
|
||||||
|
'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300';
|
||||||
|
|
||||||
|
const HEALTH_DOT_CLASS: Record<ServiceCatalog['healthStatus'], string> = {
|
||||||
|
UP: 'bg-green-500',
|
||||||
|
DOWN: 'bg-red-500',
|
||||||
|
UNKNOWN: 'bg-gray-400',
|
||||||
|
};
|
||||||
|
|
||||||
|
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 [openServices, setOpenServices] = useState<Record<number, boolean>>({});
|
||||||
|
const [openDomains, setOpenDomains] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getCatalog()
|
||||||
|
.then((res) => {
|
||||||
|
const items = res.data ?? [];
|
||||||
|
setCatalog(items);
|
||||||
|
// Open all service groups and domain groups by default
|
||||||
|
const serviceState: Record<number, boolean> = {};
|
||||||
|
const domainState: Record<string, boolean> = {};
|
||||||
|
items.forEach((svc) => {
|
||||||
|
serviceState[svc.serviceId] = true;
|
||||||
|
svc.domains.forEach((dg) => {
|
||||||
|
domainState[`${svc.serviceId}:${dg.domain}`] = true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
setOpenServices(serviceState);
|
||||||
|
setOpenDomains(domainState);
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleService = (serviceId: number) => {
|
||||||
|
setOpenServices((prev) => ({ ...prev, [serviceId]: !prev[serviceId] }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleDomain = (key: string) => {
|
||||||
|
setOpenDomains((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||||
|
};
|
||||||
|
|
||||||
|
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">
|
||||||
|
<div className="flex items-center gap-2 px-5 h-16">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* Navigation tree */}
|
||||||
|
<nav className="flex-1 overflow-y-auto px-3 py-4 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>
|
||||||
|
) : (
|
||||||
|
catalog.map((service) => {
|
||||||
|
const serviceOpen = openServices[service.serviceId] ?? false;
|
||||||
|
const healthDot = HEALTH_DOT_CLASS[service.healthStatus];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={service.serviceId} className="mb-1">
|
||||||
|
{/* Service group header */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/api-hub/services/${service.serviceId}`)}
|
||||||
|
className="flex flex-1 min-w-0 items-center gap-2 rounded-l-lg px-3 py-2 text-sm font-semibold text-gray-200 hover:bg-gray-800 hover:text-white transition-colors text-left"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`h-2 w-2 flex-shrink-0 rounded-full ${healthDot}`}
|
||||||
|
title={service.healthStatus}
|
||||||
|
/>
|
||||||
|
<span className="truncate">{service.serviceName}</span>
|
||||||
|
<span className="ml-auto flex-shrink-0 rounded-full bg-gray-700 px-1.5 py-0.5 text-xs text-gray-300">
|
||||||
|
{service.apiCount}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => toggleService(service.serviceId)}
|
||||||
|
className="flex-shrink-0 rounded-r-lg p-2 text-gray-400 hover:bg-gray-800 hover:text-white transition-colors"
|
||||||
|
aria-label={serviceOpen ? 'Collapse' : 'Expand'}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className={`h-3.5 w-3.5 transition-transform ${serviceOpen ? '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>
|
||||||
|
|
||||||
|
{/* Domain groups */}
|
||||||
|
{serviceOpen && (
|
||||||
|
<div className="ml-3 mt-0.5 space-y-0.5">
|
||||||
|
{service.domains.map((dg) => {
|
||||||
|
const domainKey = `${service.serviceId}:${dg.domain}`;
|
||||||
|
const domainOpen = openDomains[domainKey] ?? false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={dg.domain}>
|
||||||
|
{/* Domain header */}
|
||||||
|
<button
|
||||||
|
onClick={() => toggleDomain(domainKey)}
|
||||||
|
className="flex w-full items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs font-medium text-gray-400 hover:bg-gray-800 hover:text-gray-200 transition-colors"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className={`h-3 w-3 flex-shrink-0 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>
|
||||||
|
<span className="truncate uppercase tracking-wider">{dg.domain}</span>
|
||||||
|
<span className="ml-auto flex-shrink-0 text-gray-500">
|
||||||
|
{dg.apis.length}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* API items */}
|
||||||
|
{domainOpen && (
|
||||||
|
<div className="ml-3 mt-0.5 space-y-0.5">
|
||||||
|
{dg.apis.map((api) => {
|
||||||
|
const apiPath = `/api-hub/services/${service.serviceId}/apis/${api.apiId}`;
|
||||||
|
const isActive = location.pathname === apiPath;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NavLink
|
||||||
|
key={api.apiId}
|
||||||
|
to={apiPath}
|
||||||
|
className={`flex items-center gap-2 rounded-lg px-2.5 py-1.5 text-xs transition-colors ${
|
||||||
|
isActive
|
||||||
|
? 'bg-gray-700 text-white'
|
||||||
|
: 'text-gray-300 hover:bg-gray-800 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`flex-shrink-0 rounded px-1 py-0.5 text-[10px] font-bold leading-none ${getMethodBadgeClass(api.apiMethod)}`}
|
||||||
|
>
|
||||||
|
{api.apiMethod.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
<span className="truncate">{api.apiName}</span>
|
||||||
|
</NavLink>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</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;
|
||||||
@ -91,6 +91,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;
|
||||||
|
|||||||
297
frontend/src/pages/apihub/ApiHubApiDetailPage.tsx
Normal file
297
frontend/src/pages/apihub/ApiHubApiDetailPage.tsx
Normal file
@ -0,0 +1,297 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import type { ServiceCatalog, ServiceApiItem } from '../../types/apihub';
|
||||||
|
import { getCatalog } 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 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 formatDateTime = (dateStr: string): string => {
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
const date = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||||
|
const time = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||||
|
return `${date} ${time}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface LabelValueRowProps {
|
||||||
|
label: string;
|
||||||
|
value: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LabelValueRow = ({ label, value }: LabelValueRowProps) => (
|
||||||
|
<div className="flex items-start py-3 border-b border-gray-100 dark:border-gray-700 last:border-0">
|
||||||
|
<dt className="w-36 shrink-0 text-sm font-medium text-gray-500 dark:text-gray-400">{label}</dt>
|
||||||
|
<dd className="flex-1 text-sm text-gray-900 dark:text-gray-100">{value}</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ApiHubApiDetailPage = () => {
|
||||||
|
const { serviceId, apiId } = useParams<{ serviceId: string; apiId: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [api, setApi] = useState<ServiceApiItem | null>(null);
|
||||||
|
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 || !apiId) return;
|
||||||
|
try {
|
||||||
|
const res = await getCatalog();
|
||||||
|
if (res.success && res.data) {
|
||||||
|
const foundService = res.data.find((s) => s.serviceId === Number(serviceId));
|
||||||
|
if (foundService) {
|
||||||
|
setService(foundService);
|
||||||
|
const foundApi = foundService.domains
|
||||||
|
.flatMap((d) => d.apis)
|
||||||
|
.find((a) => a.apiId === Number(apiId));
|
||||||
|
if (foundApi) {
|
||||||
|
setApi(foundApi);
|
||||||
|
} else {
|
||||||
|
setError('API를 찾을 수 없습니다');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setError('서비스를 찾을 수 없습니다');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setError('API 정보를 불러오지 못했습니다');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError('API 정보를 불러오는 중 오류가 발생했습니다');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [serviceId, apiId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
}, [fetchData]);
|
||||||
|
|
||||||
|
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 || !api) {
|
||||||
|
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 domainLabel = api.apiDomain || '기타';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* 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/services/${serviceId}`)}
|
||||||
|
className="hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
||||||
|
>
|
||||||
|
{service?.serviceName ?? `서비스 ${serviceId}`}
|
||||||
|
</button>
|
||||||
|
<span>/</span>
|
||||||
|
<span className="text-gray-700 dark:text-gray-300">{domainLabel}</span>
|
||||||
|
<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-start 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>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-mono text-base text-gray-700 dark:text-gray-300 truncate mb-1" title={api.apiPath}>
|
||||||
|
{api.apiPath}
|
||||||
|
</p>
|
||||||
|
<h1 className="text-xl font-bold text-gray-900 dark:text-gray-100">{api.apiName}</h1>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`shrink-0 px-2 py-1 rounded-full text-xs font-medium ${
|
||||||
|
api.isActive
|
||||||
|
? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300'
|
||||||
|
: 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{api.isActive ? '활성' : '비활성'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
{/* 기본 정보 */}
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 mb-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>
|
||||||
|
<dl>
|
||||||
|
<LabelValueRow label="API명" value={api.apiName} />
|
||||||
|
<LabelValueRow
|
||||||
|
label="메서드"
|
||||||
|
value={
|
||||||
|
<span
|
||||||
|
className={`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>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<LabelValueRow
|
||||||
|
label="경로"
|
||||||
|
value={<span className="font-mono text-sm">{api.apiPath}</span>}
|
||||||
|
/>
|
||||||
|
<LabelValueRow
|
||||||
|
label="도메인"
|
||||||
|
value={
|
||||||
|
api.apiDomain ? (
|
||||||
|
<span className="px-2 py-0.5 rounded-full bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 text-xs font-medium">
|
||||||
|
{api.apiDomain}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-400 dark:text-gray-500">-</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<LabelValueRow
|
||||||
|
label="섹션"
|
||||||
|
value={
|
||||||
|
api.apiSection ? (
|
||||||
|
api.apiSection
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-400 dark:text-gray-500">-</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<LabelValueRow
|
||||||
|
label="활성 상태"
|
||||||
|
value={
|
||||||
|
<span
|
||||||
|
className={`px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||||
|
api.isActive
|
||||||
|
? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300'
|
||||||
|
: 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{api.isActive ? '활성' : '비활성'}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<LabelValueRow label="등록일시" value={formatDateTime(api.createdAt)} />
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 설명 */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 mb-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>
|
||||||
|
{api.description ? (
|
||||||
|
<p className="text-sm text-gray-700 dark:text-gray-300 leading-relaxed whitespace-pre-wrap">
|
||||||
|
{api.description}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-gray-400 dark:text-gray-500">설명이 없습니다</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 요청 정보 */}
|
||||||
|
<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>
|
||||||
|
<div className="flex items-center gap-3 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 ${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">{api.apiPath}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-400 dark:text-gray-500 mt-3">
|
||||||
|
상세 요청/응답 명세는 추후 제공될 예정입니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 사이드: 서비스 정보 */}
|
||||||
|
<div>
|
||||||
|
<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>
|
||||||
|
{service ? (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-1">{service.serviceName}</p>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 font-mono mb-3">{service.serviceCode}</p>
|
||||||
|
{service.description && (
|
||||||
|
<p className="text-xs text-gray-600 dark:text-gray-400 mb-4 leading-relaxed">{service.description}</p>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={`w-2 h-2 rounded-full ${
|
||||||
|
service.healthStatus === 'UP'
|
||||||
|
? 'bg-green-500'
|
||||||
|
: service.healthStatus === 'DOWN'
|
||||||
|
? 'bg-red-500'
|
||||||
|
: 'bg-gray-400'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={`text-xs font-medium ${
|
||||||
|
service.healthStatus === 'UP'
|
||||||
|
? 'text-green-600 dark:text-green-400'
|
||||||
|
: service.healthStatus === 'DOWN'
|
||||||
|
? 'text-red-600 dark:text-red-400'
|
||||||
|
: 'text-gray-500 dark:text-gray-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{service.healthStatus === 'UP' ? '정상' : service.healthStatus === 'DOWN' ? '중단' : '알 수 없음'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/api-hub/services/${serviceId}`)}
|
||||||
|
className="mt-4 w-full text-center text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 font-medium py-2 border border-blue-200 dark:border-blue-700 rounded-lg hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors"
|
||||||
|
>
|
||||||
|
서비스 API 목록 보기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-gray-400 dark:text-gray-500">서비스 정보를 불러올 수 없습니다</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ApiHubApiDetailPage;
|
||||||
241
frontend/src/pages/apihub/ApiHubDashboardPage.tsx
Normal file
241
frontend/src/pages/apihub/ApiHubDashboardPage.tsx
Normal file
@ -0,0 +1,241 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import type { ServiceCatalog, RecentApi } from '../../types/apihub';
|
||||||
|
import type { TopApi } from '../../types/dashboard';
|
||||||
|
import { getCatalog, getRecentApis } from '../../services/apiHubService';
|
||||||
|
import { getTopApis } from '../../services/dashboardService';
|
||||||
|
|
||||||
|
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_LABEL: Record<string, string> = {
|
||||||
|
UP: '정상',
|
||||||
|
DOWN: '중단',
|
||||||
|
UNKNOWN: '알 수 없음',
|
||||||
|
};
|
||||||
|
|
||||||
|
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')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ApiHubDashboardPage = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [catalog, setCatalog] = useState<ServiceCatalog[]>([]);
|
||||||
|
const [recentApis, setRecentApis] = useState<RecentApi[]>([]);
|
||||||
|
const [topApis, setTopApis] = useState<TopApi[]>([]);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
const fetchAll = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const [catalogRes, recentRes, topRes] = await Promise.allSettled([
|
||||||
|
getCatalog(),
|
||||||
|
getRecentApis(),
|
||||||
|
getTopApis(5),
|
||||||
|
]);
|
||||||
|
setCatalog(extractSettled<ServiceCatalog[]>(catalogRes, []));
|
||||||
|
setRecentApis(extractSettled<RecentApi[]>(recentRes, []));
|
||||||
|
setTopApis(extractSettled<TopApi[]>(topRes, []));
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAll();
|
||||||
|
}, [fetchAll]);
|
||||||
|
|
||||||
|
const filteredCatalog = catalog.filter((svc) => {
|
||||||
|
if (!searchQuery) return true;
|
||||||
|
const q = searchQuery.toLowerCase();
|
||||||
|
return (
|
||||||
|
svc.serviceName.toLowerCase().includes(q) ||
|
||||||
|
svc.serviceCode.toLowerCase().includes(q) ||
|
||||||
|
(svc.description ?? '').toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredRecentApis = recentApis.filter((api) => {
|
||||||
|
if (!searchQuery) return true;
|
||||||
|
const q = searchQuery.toLowerCase();
|
||||||
|
return (
|
||||||
|
api.apiName.toLowerCase().includes(q) ||
|
||||||
|
api.apiPath.toLowerCase().includes(q) ||
|
||||||
|
api.serviceName.toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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>
|
||||||
|
{/* Hero Banner */}
|
||||||
|
<div className="rounded-xl bg-gradient-to-r from-blue-600 to-indigo-700 dark:from-blue-800 dark:to-indigo-900 p-8 mb-8">
|
||||||
|
<h1 className="text-3xl font-bold text-white mb-2">S&P API HUB</h1>
|
||||||
|
<p className="text-blue-100 mb-6">서비스 API를 탐색하고, 명세를 확인하세요</p>
|
||||||
|
<div className="max-w-xl">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="서비스명, API명, 경로 검색..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="w-full px-4 py-3 rounded-lg text-gray-900 bg-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-300 shadow"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 인기 API 섹션 */}
|
||||||
|
{topApis.length > 0 && (
|
||||||
|
<div className="mb-8">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">인기 API</h2>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-4">
|
||||||
|
{topApis.map((api, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 border border-gray-100 dark:border-gray-700"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="text-xs font-bold text-gray-400 dark:text-gray-500">#{idx + 1}</span>
|
||||||
|
<span className="text-xs px-2 py-0.5 rounded-full bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300 font-medium truncate">
|
||||||
|
{api.serviceName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100 truncate mb-3" title={api.apiName}>
|
||||||
|
{api.apiName}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<span>{api.count.toLocaleString()} 호출</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 최신 등록 API 섹션 */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">최신 등록 API</h2>
|
||||||
|
{filteredRecentApis.length > 0 ? (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{filteredRecentApis.map((api) => (
|
||||||
|
<div
|
||||||
|
key={api.apiId}
|
||||||
|
className="bg-white dark:bg-gray-800 rounded-lg shadow p-5 border border-gray-100 dark:border-gray-700 cursor-pointer hover:shadow-md hover:border-blue-300 dark:hover:border-blue-600 transition-all"
|
||||||
|
onClick={() => navigate(`/api-hub/services/${api.serviceId}/apis/${api.apiId}`)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span
|
||||||
|
className={`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>
|
||||||
|
<span className="text-xs px-2 py-0.5 rounded-full bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300 font-medium">
|
||||||
|
{api.serviceName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-1 truncate" title={api.apiName}>
|
||||||
|
{api.apiName}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 font-mono mb-2 truncate" title={api.apiPath}>
|
||||||
|
{api.apiPath}
|
||||||
|
</p>
|
||||||
|
{api.description && (
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3 line-clamp-2">
|
||||||
|
{truncate(api.description, 80)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-gray-400 dark:text-gray-500">{formatDate(api.createdAt)} 등록</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-8 text-center text-gray-400 dark:text-gray-500">
|
||||||
|
{searchQuery ? '검색 결과가 없습니다' : '등록된 API가 없습니다'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 서비스 카드 섹션 */}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">서비스 목록</h2>
|
||||||
|
{filteredCatalog.length > 0 ? (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{filteredCatalog.map((svc) => (
|
||||||
|
<div
|
||||||
|
key={svc.serviceId}
|
||||||
|
className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 border border-gray-100 dark:border-gray-700 cursor-pointer hover:shadow-md hover:border-blue-300 dark:hover:border-blue-600 transition-all"
|
||||||
|
onClick={() => navigate(`/api-hub/services/${svc.serviceId}`)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="text-base font-semibold text-gray-900 dark:text-gray-100 truncate">
|
||||||
|
{svc.serviceName}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 font-mono">{svc.serviceCode}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 ml-3 shrink-0">
|
||||||
|
<div className={`w-2.5 h-2.5 rounded-full ${HEALTH_DOT[svc.healthStatus] ?? 'bg-gray-400'}`} />
|
||||||
|
<span
|
||||||
|
className={`text-xs font-medium ${
|
||||||
|
svc.healthStatus === 'UP'
|
||||||
|
? 'text-green-600 dark:text-green-400'
|
||||||
|
: svc.healthStatus === 'DOWN'
|
||||||
|
? 'text-red-600 dark:text-red-400'
|
||||||
|
: 'text-gray-500 dark:text-gray-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{HEALTH_LABEL[svc.healthStatus] ?? svc.healthStatus}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{svc.description && (
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4 line-clamp-2">
|
||||||
|
{svc.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-4 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<span>API {svc.apiCount}개</span>
|
||||||
|
<span>도메인 {svc.domains.length}개</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-8 text-center text-gray-400 dark:text-gray-500">
|
||||||
|
{searchQuery ? '검색 결과가 없습니다' : '등록된 서비스가 없습니다'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ApiHubDashboardPage;
|
||||||
224
frontend/src/pages/apihub/ApiHubServicePage.tsx
Normal file
224
frontend/src/pages/apihub/ApiHubServicePage.tsx
Normal file
@ -0,0 +1,224 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import type { ServiceCatalog, ServiceApiItem } from '../../types/apihub';
|
||||||
|
import { getCatalog } 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: '알 수 없음',
|
||||||
|
};
|
||||||
|
|
||||||
|
const truncate = (str: string, max: number): string =>
|
||||||
|
str.length > max ? str.slice(0, max) + '...' : str;
|
||||||
|
|
||||||
|
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">
|
||||||
|
<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-24">메서드</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 w-16">상태</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 max-w-xs truncate" title={api.apiPath}>
|
||||||
|
{api.apiPath}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-gray-900 dark:text-gray-100 font-medium max-w-xs truncate" title={api.apiName}>
|
||||||
|
{api.apiName}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-gray-500 dark:text-gray-400 max-w-sm">
|
||||||
|
{api.description ? truncate(api.description, 60) : <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 getCatalog();
|
||||||
|
if (res.success && res.data) {
|
||||||
|
const found = res.data.find((s) => s.serviceId === Number(serviceId));
|
||||||
|
if (found) {
|
||||||
|
setService(found);
|
||||||
|
} else {
|
||||||
|
setError('서비스를 찾을 수 없습니다');
|
||||||
|
}
|
||||||
|
} 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 || '기타';
|
||||||
|
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>
|
||||||
|
<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;
|
||||||
5
frontend/src/services/apiHubService.ts
Normal file
5
frontend/src/services/apiHubService.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { get } from './apiClient';
|
||||||
|
import type { ServiceCatalog, RecentApi } from '../types/apihub';
|
||||||
|
|
||||||
|
export const getCatalog = () => get<ServiceCatalog[]>('/api-hub/catalog');
|
||||||
|
export const getRecentApis = () => get<RecentApi[]>('/api-hub/recent-apis');
|
||||||
41
frontend/src/types/apihub.ts
Normal file
41
frontend/src/types/apihub.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
export interface DomainGroup {
|
||||||
|
domain: string;
|
||||||
|
apis: ServiceApiItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
healthStatus: 'UP' | 'DOWN' | 'UNKNOWN';
|
||||||
|
apiCount: number;
|
||||||
|
domains: DomainGroup[];
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@ -0,0 +1,42 @@
|
|||||||
|
package com.gcsc.connection.apihub.controller;
|
||||||
|
|
||||||
|
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 lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 활성 서비스와 해당 서비스의 활성 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
package com.gcsc.connection.apihub.dto;
|
||||||
|
|
||||||
|
import com.gcsc.connection.service.dto.ServiceApiResponse;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public record DomainGroup(
|
||||||
|
String domain,
|
||||||
|
List<ServiceApiResponse> apis
|
||||||
|
) {
|
||||||
|
}
|
||||||
@ -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,43 @@
|
|||||||
|
package com.gcsc.connection.apihub.dto;
|
||||||
|
|
||||||
|
import com.gcsc.connection.service.dto.ServiceApiResponse;
|
||||||
|
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 description,
|
||||||
|
String healthStatus,
|
||||||
|
int apiCount,
|
||||||
|
List<DomainGroup> domains
|
||||||
|
) {
|
||||||
|
|
||||||
|
public static ServiceCatalogResponse from(SnpService service, List<SnpServiceApi> apis) {
|
||||||
|
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(Map.Entry.comparingByKey())
|
||||||
|
.map(entry -> new DomainGroup(entry.getKey(), entry.getValue()))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
return new ServiceCatalogResponse(
|
||||||
|
service.getServiceId(),
|
||||||
|
service.getServiceCode(),
|
||||||
|
service.getServiceName(),
|
||||||
|
service.getDescription(),
|
||||||
|
service.getHealthStatus().name(),
|
||||||
|
apis.size(),
|
||||||
|
domainGroups
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,49 @@
|
|||||||
|
package com.gcsc.connection.apihub.service;
|
||||||
|
|
||||||
|
import com.gcsc.connection.apihub.dto.RecentApiResponse;
|
||||||
|
import com.gcsc.connection.apihub.dto.ServiceCatalogResponse;
|
||||||
|
import com.gcsc.connection.service.entity.SnpService;
|
||||||
|
import com.gcsc.connection.service.entity.SnpServiceApi;
|
||||||
|
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 java.util.List;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@Slf4j
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ApiHubService {
|
||||||
|
|
||||||
|
private final SnpServiceRepository snpServiceRepository;
|
||||||
|
private final SnpServiceApiRepository snpServiceApiRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 활성 서비스와 각 서비스의 활성 API를 도메인별로 그룹화하여 카탈로그 반환
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<ServiceCatalogResponse> getCatalog() {
|
||||||
|
List<SnpService> activeServices = snpServiceRepository.findByIsActiveTrue();
|
||||||
|
|
||||||
|
return activeServices.stream()
|
||||||
|
.map(service -> {
|
||||||
|
List<SnpServiceApi> activeApis = snpServiceApiRepository
|
||||||
|
.findByServiceServiceIdAndIsActiveTrue(service.getServiceId());
|
||||||
|
return ServiceCatalogResponse.from(service, activeApis);
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 최근 등록된 활성 API 상위 10건 반환
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<RecentApiResponse> getRecentApis() {
|
||||||
|
return snpServiceApiRepository.findTop10ByIsActiveTrueOrderByCreatedAtDesc().stream()
|
||||||
|
.map(RecentApiResponse::from)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user