Merge pull request 'feat(api-hub): S&P API HUB 기능 강화 (#42)' (#43) from feature/ISSUE-42-api-hub-enhance into develop

This commit is contained in:
HYOJIN 2026-04-14 15:49:56 +09:00
커밋 355e203691
67개의 변경된 파일4692개의 추가작업 그리고 892개의 파일을 삭제

파일 보기

@ -13,6 +13,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/ko/1.0.0/).
- 서비스별 API 목록 페이지 (도메인별 그룹) (#40) - 서비스별 API 목록 페이지 (도메인별 그룹) (#40)
- 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]

Binary file not shown.

After

Width:  |  Height:  |  크기: 658 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 4.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 2.7 MiB

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';
@ -22,6 +26,7 @@ import ApiHubLayout from './layouts/ApiHubLayout';
import ApiHubDashboardPage from './pages/apihub/ApiHubDashboardPage'; import ApiHubDashboardPage from './pages/apihub/ApiHubDashboardPage';
import ApiHubServicePage from './pages/apihub/ApiHubServicePage'; import ApiHubServicePage from './pages/apihub/ApiHubServicePage';
import ApiHubApiDetailPage from './pages/apihub/ApiHubApiDetailPage'; 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';
@ -49,12 +54,17 @@ 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 element={<ApiHubLayout />}>
<Route path="/api-hub" element={<ApiHubDashboardPage />} /> <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" element={<ApiHubServicePage />} />
<Route path="/api-hub/services/:serviceId/apis/:apiId" element={<ApiHubApiDetailPage />} /> <Route path="/api-hub/services/:serviceId/apis/:apiId" element={<ApiHubApiDetailPage />} />
</Route> </Route>

파일 보기

@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useMemo } from 'react';
import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom'; import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '../hooks/useAuth'; import { useAuth } from '../hooks/useAuth';
import { useTheme } from '../hooks/useTheme'; import { useTheme } from '../hooks/useTheme';
@ -7,22 +7,28 @@ import type { ServiceCatalog } from '../types/apihub';
const ROLES = ['ADMIN', 'MANAGER', 'USER'] as const; const ROLES = ['ADMIN', 'MANAGER', 'USER'] as const;
const METHOD_BADGE_CLASS: Record<string, string> = { 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'];
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', /** iconPath 문자열에서 SVG path d 값 배열을 추출 */
PUT: 'bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300', const parseIconPaths = (iconPath: string | null): string[] => {
DELETE: 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300', 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];
}; };
const getMethodBadgeClass = (method: string): string => interface FlatDomainGroup {
METHOD_BADGE_CLASS[method.toUpperCase()] ?? domain: string;
'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'; iconPath: string | null;
sortOrder: number;
const HEALTH_DOT_CLASS: Record<ServiceCatalog['healthStatus'], string> = { apis: { serviceId: number; apiId: number; apiName: string; apiPath: string; apiMethod: string }[];
UP: 'bg-green-500', }
DOWN: 'bg-red-500',
UNKNOWN: 'bg-gray-400',
};
const ApiHubLayout = () => { const ApiHubLayout = () => {
const { user, setRole } = useAuth(); const { user, setRole } = useAuth();
@ -32,44 +38,78 @@ const ApiHubLayout = () => {
const [catalog, setCatalog] = useState<ServiceCatalog[]>([]); const [catalog, setCatalog] = useState<ServiceCatalog[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [openServices, setOpenServices] = useState<Record<number, boolean>>({});
const [openDomains, setOpenDomains] = useState<Record<string, boolean>>({}); const [openDomains, setOpenDomains] = useState<Record<string, boolean>>({});
const [searchQuery, setSearchQuery] = useState('');
useEffect(() => { useEffect(() => {
getCatalog() getCatalog()
.then((res) => { .then((res) => {
const items = res.data ?? []; const items = res.data ?? [];
setCatalog(items); 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)); .finally(() => setLoading(false));
}, []); }, []);
const toggleService = (serviceId: number) => {
setOpenServices((prev) => ({ ...prev, [serviceId]: !prev[serviceId] }));
};
const toggleDomain = (key: string) => { const toggleDomain = (key: string) => {
setOpenDomains((prev) => ({ ...prev, [key]: !prev[key] })); 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 ( return (
<div className="flex min-h-screen"> <div className="flex min-h-screen">
{/* Sidebar */} {/* Sidebar */}
<aside className="fixed left-0 top-0 h-screen w-72 bg-gray-900 text-white flex flex-col"> <aside className="fixed left-0 top-0 h-screen w-72 bg-gray-900 text-white flex flex-col">
{/* Sidebar header */} {/* Sidebar header */}
<div className="flex-shrink-0 border-b border-gray-700"> <div className="flex-shrink-0 border-b border-gray-700">
<div className="flex items-center gap-2 px-5 h-16"> <button
onClick={() => navigate('/api-hub')}
className="flex items-center gap-2 px-5 h-16 w-full hover:bg-gray-800 transition-colors"
>
<svg <svg
className="h-5 w-5 text-blue-400 flex-shrink-0" className="h-5 w-5 text-blue-400 flex-shrink-0"
fill="none" fill="none"
@ -84,7 +124,7 @@ const ApiHubLayout = () => {
/> />
</svg> </svg>
<span className="text-base font-bold tracking-wide text-white">S&amp;P API HUB</span> <span className="text-base font-bold tracking-wide text-white">S&amp;P API HUB</span>
</div> </button>
<div className="px-5 pb-3"> <div className="px-5 pb-3">
<button <button
onClick={() => navigate('/dashboard')} onClick={() => navigate('/dashboard')}
@ -98,8 +138,39 @@ const ApiHubLayout = () => {
</div> </div>
</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 */} {/* Navigation tree */}
<nav className="flex-1 overflow-y-auto px-3 py-4 space-y-1"> <nav className="flex-1 overflow-y-auto px-3 py-2 space-y-1">
{loading ? ( {loading ? (
<div className="flex items-center justify-center py-10"> <div className="flex items-center justify-center py-10">
<svg <svg
@ -122,35 +193,47 @@ const ApiHubLayout = () => {
/> />
</svg> </svg>
</div> </div>
) : filteredDomainGroups.length === 0 ? (
<p className="text-xs text-gray-500 text-center py-6"> </p>
) : ( ) : (
catalog.map((service) => { filteredDomainGroups.map((dg) => {
const serviceOpen = openServices[service.serviceId] ?? false; const domainOpen = isSearching || (openDomains[dg.domain] ?? true);
const healthDot = HEALTH_DOT_CLASS[service.healthStatus];
return ( return (
<div key={service.serviceId} className="mb-1"> <div key={dg.domain}>
{/* Service group header */} {/* Domain header */}
<div className="flex items-center gap-1"> <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 <button
onClick={() => navigate(`/api-hub/services/${service.serviceId}`)} onClick={() => navigate(`/api-hub/domains/${encodeURIComponent(dg.domain)}`)}
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" className="flex flex-1 items-center gap-2 px-3 py-2 min-w-0"
>
<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 <svg
className={`h-3.5 w-3.5 transition-transform ${serviceOpen ? 'rotate-90' : ''}`} 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" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor" stroke="currentColor"
@ -160,63 +243,25 @@ const ApiHubLayout = () => {
</button> </button>
</div> </div>
{/* Domain groups */} {/* API items */}
{serviceOpen && ( {domainOpen && (
<div className="ml-3 mt-0.5 space-y-0.5"> <div className="ml-4 mt-0.5 space-y-0.5">
{service.domains.map((dg) => { {dg.apis.map((api) => {
const domainKey = `${service.serviceId}:${dg.domain}`; const apiPath = `/api-hub/services/${api.serviceId}/apis/${api.apiId}`;
const domainOpen = openDomains[domainKey] ?? false; const isActive = location.pathname === apiPath;
return ( return (
<div key={dg.domain}> <NavLink
{/* Domain header */} key={`${api.serviceId}-${api.apiId}`}
<button to={apiPath}
onClick={() => toggleDomain(domainKey)} className={`block rounded-lg px-2.5 py-1.5 text-xs truncate transition-colors ${
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" isActive
> ? 'bg-gray-700 text-white'
<svg : 'text-gray-300 hover:bg-gray-800 hover:text-white'
className={`h-3 w-3 flex-shrink-0 transition-transform ${domainOpen ? 'rotate-90' : ''}`} }`}
fill="none" >
viewBox="0 0 24 24" {api.apiName}
stroke="currentColor" </NavLink>
>
<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>

파일 보기

@ -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' },
], ],

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

@ -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 &apos; URL &apos; .
</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) => {
setSelectedService(service); if (selectedService?.serviceId === service.serviceId) {
fetchApis(service.serviceId); setSelectedService(null);
setServiceApis([]);
} else {
setSelectedService(service);
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,15 +267,26 @@ const ServicesPage = () => {
</span> </span>
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<button <div className="flex items-center gap-2">
onClick={(e) => { <button
e.stopPropagation(); onClick={(e) => {
handleOpenEditService(service); e.stopPropagation();
}} handleOpenEditService(service);
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" }}
> 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>
<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>
); );
}; };

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다. Load Diff

파일 보기

@ -1,29 +1,60 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import type { ServiceCatalog, RecentApi } from '../../types/apihub'; import type { RecentApi, PopularApi, ServiceCatalog } from '../../types/apihub';
import type { TopApi } from '../../types/dashboard'; import { getRecentApis, getPopularApis, getCatalog } from '../../services/apiHubService';
import { getCatalog, getRecentApis } from '../../services/apiHubService';
import { getTopApis } from '../../services/dashboardService';
const METHOD_COLORS: Record<string, string> = { const formatDomain = (d: string) => (/^[a-zA-Z\s\-_]+$/.test(d) ? d.toUpperCase() : d);
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', const DOMAIN_COLOR_PALETTE = [
PATCH: 'bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300', { color: 'text-emerald-400', bg: 'bg-emerald-500/10', border: 'border-emerald-500/30', line: 'from-emerald-500' },
DELETE: 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300', { 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 HEALTH_DOT: Record<string, string> = { const domainColorCache = new Map<string, (typeof DOMAIN_COLOR_PALETTE)[0]>();
UP: 'bg-green-500', let nextColorIdx = 0;
DOWN: 'bg-red-500',
UNKNOWN: 'bg-gray-400', 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 HEALTH_LABEL: Record<string, string> = { const RANK_BADGE_STYLES = [
UP: '정상', 'bg-gradient-to-br from-yellow-400 to-amber-500',
DOWN: '중단', 'bg-gradient-to-br from-gray-300 to-gray-400',
UNKNOWN: '알 수 없음', 'bg-gradient-to-br from-amber-600 to-amber-700',
}; ];
const extractSettled = <T,>(result: PromiseSettledResult<{ data?: T }>, fallback: T): T => { const extractSettled = <T,>(result: PromiseSettledResult<{ data?: T }>, fallback: T): T => {
if (result.status === 'fulfilled' && result.value.data !== undefined) { if (result.status === 'fulfilled' && result.value.data !== undefined) {
@ -40,24 +71,30 @@ const formatDate = (dateStr: string): string => {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; 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 ApiHubDashboardPage = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const [catalog, setCatalog] = useState<ServiceCatalog[]>([]); const [catalog, setCatalog] = useState<ServiceCatalog[]>([]);
const [recentApis, setRecentApis] = useState<RecentApi[]>([]); const [recentApis, setRecentApis] = useState<RecentApi[]>([]);
const [topApis, setTopApis] = useState<TopApi[]>([]); const [popularApis, setPopularApis] = useState<PopularApi[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const fetchAll = useCallback(async () => { const fetchAll = useCallback(async () => {
try { try {
const [catalogRes, recentRes, topRes] = await Promise.allSettled([ const [catalogRes, recentRes, popularRes] = await Promise.allSettled([
getCatalog(), getCatalog(),
getRecentApis(), getRecentApis(),
getTopApis(5), getPopularApis(),
]); ]);
setCatalog(extractSettled<ServiceCatalog[]>(catalogRes, [])); setCatalog(extractSettled<ServiceCatalog[]>(catalogRes, []));
setRecentApis(extractSettled<RecentApi[]>(recentRes, [])); setRecentApis(extractSettled<RecentApi[]>(recentRes, []));
setTopApis(extractSettled<TopApi[]>(topRes, [])); setPopularApis(extractSettled<PopularApi[]>(popularRes, []));
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@ -67,25 +104,26 @@ const ApiHubDashboardPage = () => {
fetchAll(); fetchAll();
}, [fetchAll]); }, [fetchAll]);
const filteredCatalog = catalog.filter((svc) => { // 카탈로그에서 도메인 기준으로 플랫하게 집계
if (!searchQuery) return true; const domainList = useMemo<FlatDomain[]>(() => {
const q = searchQuery.toLowerCase(); const map = new Map<string, { iconPath: string | null; sortOrder: number; apiCount: number }>();
return ( for (const svc of catalog) {
svc.serviceName.toLowerCase().includes(q) || for (const dg of svc.domains) {
svc.serviceCode.toLowerCase().includes(q) || const key = dg.domain.toUpperCase();
(svc.description ?? '').toLowerCase().includes(q) 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 filteredRecentApis = recentApis.filter((api) => { const recentTop3 = recentApis.slice(0, 3);
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) { if (isLoading) {
return ( return (
@ -96,144 +134,210 @@ const ApiHubDashboardPage = () => {
} }
return ( return (
<div> <div className="max-w-7xl mx-auto space-y-8">
{/* 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"> <div className="relative overflow-hidden rounded-2xl bg-gradient-to-br from-indigo-950 via-indigo-800 to-indigo-600 p-8">
<h1 className="text-3xl font-bold text-white mb-2">S&amp;P API HUB</h1> {/* 장식 글로우 원 */}
<p className="text-blue-100 mb-6"> API를 , </p> <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="max-w-xl"> <div className="pointer-events-none absolute right-32 -top-8 h-32 w-32 rounded-full bg-purple-400 opacity-10 blur-2xl" />
<input
type="text" {/* 제목 */}
placeholder="서비스명, API명, 경로 검색..." <h1 className="mb-2 text-4xl font-extrabold tracking-tight text-white">S&amp;P API HUB</h1>
value={searchQuery} <p className="text-indigo-200">S&amp;P / .</p>
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> </div>
{/* 인기 API 섹션 */} {/* 인기 API 섹션 */}
{topApis.length > 0 && ( {popularApis.length > 0 && (
<div className="mb-8"> <div>
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4"> API</h2> <div className="mb-4 flex items-center gap-2">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-4"> <div className="flex h-7 w-7 items-center justify-center rounded-lg bg-amber-500/20">
{topApis.map((api, idx) => ( <svg className="h-4 w-4 text-amber-400" viewBox="0 0 24 24" fill="currentColor">
<div <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" />
key={idx} </svg>
className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 border border-gray-100 dark:border-gray-700" </div>
> <h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100"> API</h2>
<div className="flex items-center gap-2 mb-2"> <span className="ml-1 text-xs text-gray-400 dark:text-gray-500"> 7 </span>
<span className="text-xs font-bold text-gray-400 dark:text-gray-500">#{idx + 1}</span> </div>
<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"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
{api.serviceName} {popularApis.map((api, idx) => {
</span> 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>
<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>
</div> </div>
)} )}
{/* 최신 등록 API 섹션 */} {/* 최신 등록 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> <div>
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4"> </h2> <div className="mb-4 flex items-center gap-2">
{filteredCatalog.length > 0 ? ( <div className="flex h-7 w-7 items-center justify-center rounded-lg bg-cyan-500/20">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <svg className="h-4 w-4 text-cyan-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
{filteredCatalog.map((svc) => ( <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" />
<div </svg>
key={svc.serviceId} </div>
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" <h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100"> API</h2>
onClick={() => navigate(`/api-hub/services/${svc.serviceId}`)} </div>
> {recentTop3.length > 0 ? (
<div className="flex items-start justify-between mb-3"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div className="flex-1 min-w-0"> {recentTop3.map((api) => {
<h3 className="text-base font-semibold text-gray-900 dark:text-gray-100 truncate"> const palette = api.apiDomain ? getDomainColorByHash(api.apiDomain) : DOMAIN_COLOR_PALETTE[4];
{svc.serviceName} return (
</h3> <div
<p className="text-xs text-gray-500 dark:text-gray-400 font-mono">{svc.serviceCode}</p> 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> </div>
<div className="flex items-center gap-1.5 ml-3 shrink-0"> <p
<div className={`w-2.5 h-2.5 rounded-full ${HEALTH_DOT[svc.healthStatus] ?? 'bg-gray-400'}`} /> className="flex-1 text-sm font-semibold text-gray-900 dark:text-gray-100 mb-1 truncate"
<span title={api.apiName}
className={`text-xs font-medium ${ >
svc.healthStatus === 'UP' {api.apiName}
? '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> </p>
)} {api.description && (
<div className="flex items-center gap-4 text-xs text-gray-500 dark:text-gray-400"> <p className="mb-3 text-xs text-gray-500 dark:text-gray-400 line-clamp-2">
<span>API {svc.apiCount}</span> {truncate(api.description, 80)}
<span> {svc.domains.length}</span> </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> );
))} })}
</div> </div>
) : ( ) : (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-8 text-center text-gray-400 dark:text-gray-500"> <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">
{searchQuery ? '검색 결과가 없습니다' : '등록된 서비스가 없습니다'} API가
</div> </div>
)} )}
</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> </div>
); );
}; };

파일 보기

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

파일 보기

@ -1,7 +1,7 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import type { ServiceCatalog, ServiceApiItem } from '../../types/apihub'; import type { ServiceCatalog, ServiceApiItem } from '../../types/apihub';
import { getCatalog } from '../../services/apiHubService'; import { getServiceCatalog } from '../../services/apiHubService';
const METHOD_COLORS: Record<string, string> = { const METHOD_COLORS: Record<string, string> = {
GET: 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300', GET: 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300',
@ -29,9 +29,6 @@ const HEALTH_LABEL: Record<string, string> = {
UNKNOWN: '알 수 없음', UNKNOWN: '알 수 없음',
}; };
const truncate = (str: string, max: number): string =>
str.length > max ? str.slice(0, max) + '...' : str;
interface DomainSectionProps { interface DomainSectionProps {
domainName: string; domainName: string;
apis: ServiceApiItem[]; apis: ServiceApiItem[];
@ -48,14 +45,21 @@ const DomainSection = ({ domainName, apis, serviceId, onNavigate }: DomainSectio
</span> </span>
</div> </div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden border border-gray-100 dark:border-gray-700"> <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 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"> <thead className="bg-gray-50 dark:bg-gray-700">
<tr> <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"></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">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>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase w-16"></th> <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase"></th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700"> <tbody className="divide-y divide-gray-200 dark:divide-gray-700">
@ -72,14 +76,14 @@ const DomainSection = ({ domainName, apis, serviceId, onNavigate }: DomainSectio
{api.apiMethod} {api.apiMethod}
</span> </span>
</td> </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}> <td className="px-4 py-3 font-mono text-xs text-gray-700 dark:text-gray-300 truncate" title={api.apiPath}>
{api.apiPath} {api.apiPath}
</td> </td>
<td className="px-4 py-3 text-gray-900 dark:text-gray-100 font-medium max-w-xs truncate" title={api.apiName}> <td className="px-4 py-3 text-gray-900 dark:text-gray-100 font-medium truncate" title={api.apiName}>
{api.apiName} {api.apiName}
</td> </td>
<td className="px-4 py-3 text-gray-500 dark:text-gray-400 max-w-sm"> <td className="px-4 py-3 text-gray-500 dark:text-gray-400 truncate" title={api.description || ''}>
{api.description ? truncate(api.description, 60) : <span className="text-gray-300 dark:text-gray-600">-</span>} {api.description || <span className="text-gray-300 dark:text-gray-600">-</span>}
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
{api.isActive ? ( {api.isActive ? (
@ -106,14 +110,9 @@ const ApiHubServicePage = () => {
const fetchData = useCallback(async () => { const fetchData = useCallback(async () => {
if (!serviceId) return; if (!serviceId) return;
try { try {
const res = await getCatalog(); const res = await getServiceCatalog(Number(serviceId));
if (res.success && res.data) { if (res.success && res.data) {
const found = res.data.find((s) => s.serviceId === Number(serviceId)); setService(res.data);
if (found) {
setService(found);
} else {
setError('서비스를 찾을 수 없습니다');
}
} else { } else {
setError('서비스 정보를 불러오지 못했습니다'); setError('서비스 정보를 불러오지 못했습니다');
} }
@ -158,7 +157,7 @@ const ApiHubServicePage = () => {
const domainsMap = new Map<string, ServiceApiItem[]>(); const domainsMap = new Map<string, ServiceApiItem[]>();
for (const dg of service.domains) { for (const dg of service.domains) {
const key = dg.domain || '기타'; const key = dg.domain ? dg.domain.toUpperCase() : '기타';
domainsMap.set(key, dg.apis); domainsMap.set(key, dg.apis);
} }
@ -166,7 +165,7 @@ const ApiHubServicePage = () => {
const domainEntries = [...domainsMap.entries()]; const domainEntries = [...domainsMap.entries()];
return ( return (
<div> <div className="max-w-7xl mx-auto">
<button <button
onClick={() => navigate('/api-hub')} 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" className="text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 mb-4 inline-block"

파일 보기

@ -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>
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100 pl-10"> {isReviewReadOnly ? (
{selectedRequest.dailyRequestEstimate != null <p className="text-sm font-semibold text-gray-900 dark:text-gray-100 pl-10">
? `${Number(selectedRequest.dailyRequestEstimate).toLocaleString()}건/일` {selectedRequest.dailyRequestEstimate != null
: '-'} ? `${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); const existing = domainMap.get(domainName)!;
for (const api of domainGroup.apis) {
existing.push({
apiId: api.apiId,
apiName: api.apiName,
description: api.description,
});
}
} }
return next; }
});
};
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,98 +484,44 @@ 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 && ( {isDomainExpanded && (
<div className="px-4 py-3 space-y-2 bg-white dark:bg-gray-900"> <div className="divide-y divide-gray-100 dark:divide-gray-700/50 bg-white dark:bg-gray-900">
{domainGroups.map((domainGroup) => { {domainApis.map((api) => {
const domainKey = `${service.serviceId}-${domainGroup.domain}`; const isSelected = selectedApiIds.has(api.apiId);
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 ( return (
<div key={domainKey}> <div
{/* Domain row */} key={api.apiId}
<div 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' : ''}`}
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={() => handleToggleApi(api.apiId)}
onClick={() => handleToggleDomain(domainKey)} >
> <div className="flex items-center pt-0.5">
<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"> <input
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /> type="checkbox"
</svg> checked={isSelected}
<label className="flex items-center cursor-pointer" onClick={(e) => e.stopPropagation()}> onChange={() => handleToggleApi(api.apiId)}
<IndeterminateCheckbox onClick={(e) => e.stopPropagation()}
checked={allDomainSelected} className="rounded"
indeterminate={someDomainSelected} />
onChange={() => handleToggleAllDomainApis(service.serviceId, domainGroup.domain)} </div>
className="rounded" <div className="flex-1 min-w-0">
/> <p className="text-sm font-semibold text-gray-900 dark:text-gray-100 truncate">{api.apiName}</p>
</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> </div>
{/* API table */}
{isDomainExpanded && (
<div className="ml-3 mr-3 mb-3 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
{/* 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) => {
const isSelected = selectedApiIds.has(api.apiId);
return (
<div
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' : ''}`}
onClick={() => handleToggleApi(api.apiId)}
>
<div className="flex items-center justify-center">
<input
type="checkbox"
checked={isSelected}
onChange={() => handleToggleApi(api.apiId)}
onClick={(e) => e.stopPropagation()}
className="rounded"
/>
</div>
<div>
<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'}`}>
{api.apiMethod}
</span>
</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>
)}
</div> </div>
); );
})} })}
{apis.length === 0 && (
<p className="text-sm text-gray-400 dark:text-gray-500 py-3 ml-5"> API가 .</p>
)}
</div> </div>
)} )}
</div> </div>
); );
})} })}
{services.length === 0 && ( {filteredDomainGroups.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>

파일 보기

@ -1,5 +1,11 @@
import { get } from './apiClient'; import { get } from './apiClient';
import type { ServiceCatalog, RecentApi } from '../types/apihub'; import type { ServiceCatalog, RecentApi, PopularApi } from '../types/apihub';
import type { ApiDetailInfo } from '../types/service';
export const getCatalog = () => get<ServiceCatalog[]>('/api-hub/catalog'); export const getCatalog = () => get<ServiceCatalog[]>('/api-hub/catalog');
export const getRecentApis = () => get<RecentApi[]>('/api-hub/recent-apis'); 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}`);

파일 보기

@ -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}`);

파일 보기

@ -1,8 +1,19 @@
export interface DomainGroup { export interface DomainGroup {
domain: string; domain: string;
iconPath: string | null;
sortOrder: number;
apis: ServiceApiItem[]; apis: ServiceApiItem[];
} }
export interface ApiDomainInfo {
domainId: number;
domainName: string;
iconPath: string | null;
sortOrder: number;
createdAt: string;
updatedAt: string;
}
export interface ServiceApiItem { export interface ServiceApiItem {
apiId: number; apiId: number;
serviceId: number; serviceId: number;
@ -22,11 +33,20 @@ export interface ServiceCatalog {
serviceCode: string; serviceCode: string;
serviceName: string; serviceName: string;
description: string | null; description: string | null;
serviceUrl: string | null;
healthStatus: 'UP' | 'DOWN' | 'UNKNOWN'; healthStatus: 'UP' | 'DOWN' | 'UNKNOWN';
apiCount: number; apiCount: number;
domains: DomainGroup[]; domains: DomainGroup[];
} }
export interface PopularApi {
domain: string;
apiName: string;
apiId: number | null;
serviceId: number | null;
count: number;
}
export interface RecentApi { export interface RecentApi {
apiId: number; apiId: number;
apiName: string; apiName: 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
파일 보기

@ -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 에서 추가/수정/삭제할 수 있습니다."

파일 보기

@ -1,19 +1,23 @@
package com.gcsc.connection.apihub.controller; 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.RecentApiResponse;
import com.gcsc.connection.apihub.dto.ServiceCatalogResponse; import com.gcsc.connection.apihub.dto.ServiceCatalogResponse;
import com.gcsc.connection.apihub.service.ApiHubService; import com.gcsc.connection.apihub.service.ApiHubService;
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.service.ServiceManagementService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
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.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import java.util.List; import java.util.List;
/** /**
* API Hub 카탈로그 최근 API 조회 컨트롤러 * API Hub 카탈로그 API 상세 조회 컨트롤러
*/ */
@RestController @RestController
@RequestMapping("/api/api-hub") @RequestMapping("/api/api-hub")
@ -21,6 +25,7 @@ import java.util.List;
public class ApiHubController { public class ApiHubController {
private final ApiHubService apiHubService; private final ApiHubService apiHubService;
private final ServiceManagementService serviceManagementService;
/** /**
* 활성 서비스와 해당 서비스의 활성 API를 도메인별로 그룹화하여 카탈로그 형태로 반환 * 활성 서비스와 해당 서비스의 활성 API를 도메인별로 그룹화하여 카탈로그 형태로 반환
@ -39,4 +44,33 @@ public class ApiHubController {
List<RecentApiResponse> recentApis = apiHubService.getRecentApis(); List<RecentApiResponse> recentApis = apiHubService.getRecentApis();
return ResponseEntity.ok(ApiResponse.ok(recentApis)); 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));
}
} }

파일 보기

@ -6,6 +6,8 @@ import java.util.List;
public record DomainGroup( public record DomainGroup(
String domain, String domain,
String iconPath,
int sortOrder,
List<ServiceApiResponse> apis 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
) {
}

파일 보기

@ -1,6 +1,7 @@
package com.gcsc.connection.apihub.dto; package com.gcsc.connection.apihub.dto;
import com.gcsc.connection.service.dto.ServiceApiResponse; 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.SnpService;
import com.gcsc.connection.service.entity.SnpServiceApi; import com.gcsc.connection.service.entity.SnpServiceApi;
@ -12,13 +13,27 @@ public record ServiceCatalogResponse(
Long serviceId, Long serviceId,
String serviceCode, String serviceCode,
String serviceName, String serviceName,
String serviceUrl,
String description, String description,
String healthStatus, String healthStatus,
int apiCount, int apiCount,
List<DomainGroup> domains List<DomainGroup> domains
) { ) {
/**
* 도메인 메타 정보(아이콘, 정렬) 없이 도메인명 기준 알파벳 정렬로 카탈로그 생성
*/
public static ServiceCatalogResponse from(SnpService service, List<SnpServiceApi> apis) { 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() Map<String, List<ServiceApiResponse>> byDomain = apis.stream()
.collect(Collectors.groupingBy( .collect(Collectors.groupingBy(
api -> api.getApiDomain() != null ? api.getApiDomain() : "", api -> api.getApiDomain() != null ? api.getApiDomain() : "",
@ -26,14 +41,29 @@ public record ServiceCatalogResponse(
)); ));
List<DomainGroup> domainGroups = byDomain.entrySet().stream() List<DomainGroup> domainGroups = byDomain.entrySet().stream()
.sorted(Map.Entry.comparingByKey()) .sorted((a, b) -> {
.map(entry -> new DomainGroup(entry.getKey(), entry.getValue())) 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(); .toList();
return new ServiceCatalogResponse( return new ServiceCatalogResponse(
service.getServiceId(), service.getServiceId(),
service.getServiceCode(), service.getServiceCode(),
service.getServiceName(), service.getServiceName(),
service.getServiceUrl(),
service.getDescription(), service.getDescription(),
service.getHealthStatus().name(), service.getHealthStatus().name(),
apis.size(), apis.size(),

파일 보기

@ -2,8 +2,12 @@ package com.gcsc.connection.apihub.service;
import com.gcsc.connection.apihub.dto.RecentApiResponse; import com.gcsc.connection.apihub.dto.RecentApiResponse;
import com.gcsc.connection.apihub.dto.ServiceCatalogResponse; 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.SnpService;
import com.gcsc.connection.service.entity.SnpServiceApi; 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.SnpServiceApiRepository;
import com.gcsc.connection.service.repository.SnpServiceRepository; import com.gcsc.connection.service.repository.SnpServiceRepository;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@ -11,7 +15,13 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import com.gcsc.connection.monitoring.repository.SnpApiRequestLogRepository;
import java.time.LocalDateTime;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
@Service @Service
@Slf4j @Slf4j
@ -20,23 +30,42 @@ public class ApiHubService {
private final SnpServiceRepository snpServiceRepository; private final SnpServiceRepository snpServiceRepository;
private final SnpServiceApiRepository snpServiceApiRepository; private final SnpServiceApiRepository snpServiceApiRepository;
private final SnpApiDomainRepository snpApiDomainRepository;
private final SnpApiRequestLogRepository snpApiRequestLogRepository;
/** /**
* 활성 서비스와 서비스의 활성 API를 도메인별로 그룹화하여 카탈로그 반환 * 활성 서비스와 서비스의 활성 API를 도메인별로 그룹화하여 카탈로그 반환
*/ */
@Transactional(readOnly = true) @Transactional(readOnly = true)
public List<ServiceCatalogResponse> getCatalog() { public List<ServiceCatalogResponse> getCatalog() {
Map<String, SnpApiDomain> domainMap = buildDomainMap();
List<SnpService> activeServices = snpServiceRepository.findByIsActiveTrue(); List<SnpService> activeServices = snpServiceRepository.findByIsActiveTrue();
return activeServices.stream() return activeServices.stream()
.map(service -> { .map(service -> {
List<SnpServiceApi> activeApis = snpServiceApiRepository List<SnpServiceApi> activeApis = snpServiceApiRepository
.findByServiceServiceIdAndIsActiveTrue(service.getServiceId()); .findByServiceServiceIdAndIsActiveTrue(service.getServiceId());
return ServiceCatalogResponse.from(service, activeApis); return ServiceCatalogResponse.from(service, activeApis, domainMap);
}) })
.toList(); .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건 반환 * 최근 등록된 활성 API 상위 10건 반환
*/ */
@ -46,4 +75,26 @@ public class ApiHubService {
.map(RecentApiResponse::from) .map(RecentApiResponse::from)
.toList(); .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,9 +371,7 @@ 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);
}

파일 보기

@ -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 생성
*/ */