From 6f2627271faec8d6af7494b64207a2afb64bda6e Mon Sep 17 00:00:00 2001 From: HYOJIN Date: Mon, 13 Apr 2026 11:28:55 +0900 Subject: [PATCH 01/12] =?UTF-8?q?feat(api-hub):=20S&P=20API=20HUB=20SPA=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(#40)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - API Hub 대시보드 (배너, 인기 API, 최신 API, 서비스 카드) - 서비스 트리 사이드바 레이아웃 (서비스 > 도메인 > API) - 서비스별 API 목록 페이지 (도메인별 그룹) - API 상세 명세 페이지 - 백엔드 카탈로그/최신 API 조회 엔드포인트 - 메인 사이드바에 API Hub 링크 추가 Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/App.tsx | 9 + frontend/src/layouts/ApiHubLayout.tsx | 290 +++++++++++++++++ frontend/src/layouts/MainLayout.tsx | 15 + .../src/pages/apihub/ApiHubApiDetailPage.tsx | 297 ++++++++++++++++++ .../src/pages/apihub/ApiHubDashboardPage.tsx | 241 ++++++++++++++ .../src/pages/apihub/ApiHubServicePage.tsx | 224 +++++++++++++ frontend/src/services/apiHubService.ts | 5 + frontend/src/types/apihub.ts | 41 +++ .../apihub/controller/ApiHubController.java | 42 +++ .../connection/apihub/dto/DomainGroup.java | 11 + .../apihub/dto/RecentApiResponse.java | 34 ++ .../apihub/dto/ServiceCatalogResponse.java | 43 +++ .../apihub/service/ApiHubService.java | 49 +++ .../repository/SnpServiceApiRepository.java | 4 + 14 files changed, 1305 insertions(+) create mode 100644 frontend/src/layouts/ApiHubLayout.tsx create mode 100644 frontend/src/pages/apihub/ApiHubApiDetailPage.tsx create mode 100644 frontend/src/pages/apihub/ApiHubDashboardPage.tsx create mode 100644 frontend/src/pages/apihub/ApiHubServicePage.tsx create mode 100644 frontend/src/services/apiHubService.ts create mode 100644 frontend/src/types/apihub.ts create mode 100644 src/main/java/com/gcsc/connection/apihub/controller/ApiHubController.java create mode 100644 src/main/java/com/gcsc/connection/apihub/dto/DomainGroup.java create mode 100644 src/main/java/com/gcsc/connection/apihub/dto/RecentApiResponse.java create mode 100644 src/main/java/com/gcsc/connection/apihub/dto/ServiceCatalogResponse.java create mode 100644 src/main/java/com/gcsc/connection/apihub/service/ApiHubService.java diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c9d77ac..c3fc7cd 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -18,6 +18,10 @@ import UserStatsPage from './pages/statistics/UserStatsPage'; import ApiStatsPage from './pages/statistics/ApiStatsPage'; import TenantStatsPage from './pages/statistics/TenantStatsPage'; import UsageTrendPage from './pages/statistics/UsageTrendPage'; +import ApiHubLayout from './layouts/ApiHubLayout'; +import ApiHubDashboardPage from './pages/apihub/ApiHubDashboardPage'; +import ApiHubServicePage from './pages/apihub/ApiHubServicePage'; +import ApiHubApiDetailPage from './pages/apihub/ApiHubApiDetailPage'; import NotFoundPage from './pages/NotFoundPage'; import RoleGuard from './components/RoleGuard'; @@ -49,6 +53,11 @@ const App = () => { } /> } /> + }> + } /> + } /> + } /> + diff --git a/frontend/src/layouts/ApiHubLayout.tsx b/frontend/src/layouts/ApiHubLayout.tsx new file mode 100644 index 0000000..b47cd56 --- /dev/null +++ b/frontend/src/layouts/ApiHubLayout.tsx @@ -0,0 +1,290 @@ +import { useState, useEffect } from 'react'; +import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom'; +import { useAuth } from '../hooks/useAuth'; +import { useTheme } from '../hooks/useTheme'; +import { getCatalog } from '../services/apiHubService'; +import type { ServiceCatalog } from '../types/apihub'; + +const ROLES = ['ADMIN', 'MANAGER', 'USER'] as const; + +const METHOD_BADGE_CLASS: Record = { + GET: 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300', + POST: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300', + PUT: 'bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300', + DELETE: 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300', +}; + +const getMethodBadgeClass = (method: string): string => + METHOD_BADGE_CLASS[method.toUpperCase()] ?? + 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'; + +const HEALTH_DOT_CLASS: Record = { + UP: 'bg-green-500', + DOWN: 'bg-red-500', + UNKNOWN: 'bg-gray-400', +}; + +const ApiHubLayout = () => { + const { user, setRole } = useAuth(); + const { theme, toggleTheme } = useTheme(); + const navigate = useNavigate(); + const location = useLocation(); + + const [catalog, setCatalog] = useState([]); + const [loading, setLoading] = useState(true); + const [openServices, setOpenServices] = useState>({}); + const [openDomains, setOpenDomains] = useState>({}); + + useEffect(() => { + getCatalog() + .then((res) => { + const items = res.data ?? []; + setCatalog(items); + // Open all service groups and domain groups by default + const serviceState: Record = {}; + const domainState: Record = {}; + items.forEach((svc) => { + serviceState[svc.serviceId] = true; + svc.domains.forEach((dg) => { + domainState[`${svc.serviceId}:${dg.domain}`] = true; + }); + }); + setOpenServices(serviceState); + setOpenDomains(domainState); + }) + .finally(() => setLoading(false)); + }, []); + + const toggleService = (serviceId: number) => { + setOpenServices((prev) => ({ ...prev, [serviceId]: !prev[serviceId] })); + }; + + const toggleDomain = (key: string) => { + setOpenDomains((prev) => ({ ...prev, [key]: !prev[key] })); + }; + + return ( +
+ {/* Sidebar */} + + + {/* Main content */} +
+ {/* Header */} +
+
+
+ + Role: +
+ {ROLES.map((role) => ( + + ))} +
+
+
+ + {/* Content */} +
+ +
+
+
+ ); +}; + +export default ApiHubLayout; diff --git a/frontend/src/layouts/MainLayout.tsx b/frontend/src/layouts/MainLayout.tsx index 64d1a23..ff16a4b 100644 --- a/frontend/src/layouts/MainLayout.tsx +++ b/frontend/src/layouts/MainLayout.tsx @@ -91,6 +91,21 @@ const MainLayout = () => { Dashboard + {/* API Hub */} + + `flex items-center gap-3 rounded-lg px-3 py-2 mt-4 text-sm font-medium transition-colors ${ + isActive ? 'bg-gray-700 text-white' : 'text-gray-300 hover:bg-gray-800 hover:text-white' + }` + } + > + + + + API Hub + + {/* Nav Groups */} {navGroups.map((group) => { if (group.adminOnly && user?.role !== 'ADMIN') return null; diff --git a/frontend/src/pages/apihub/ApiHubApiDetailPage.tsx b/frontend/src/pages/apihub/ApiHubApiDetailPage.tsx new file mode 100644 index 0000000..6ac80e2 --- /dev/null +++ b/frontend/src/pages/apihub/ApiHubApiDetailPage.tsx @@ -0,0 +1,297 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import type { ServiceCatalog, ServiceApiItem } from '../../types/apihub'; +import { getCatalog } from '../../services/apiHubService'; + +const METHOD_COLORS: Record = { + GET: 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300', + POST: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300', + PUT: 'bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300', + PATCH: 'bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300', + DELETE: 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300', +}; + +const METHOD_COLORS_LARGE: Record = { + GET: 'bg-green-500', + POST: 'bg-blue-500', + PUT: 'bg-amber-500', + PATCH: 'bg-amber-500', + DELETE: 'bg-red-500', +}; + +const formatDateTime = (dateStr: string): string => { + const d = new Date(dateStr); + const date = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; + const time = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; + return `${date} ${time}`; +}; + +interface LabelValueRowProps { + label: string; + value: React.ReactNode; +} + +const LabelValueRow = ({ label, value }: LabelValueRowProps) => ( +
+
{label}
+
{value}
+
+); + +const ApiHubApiDetailPage = () => { + const { serviceId, apiId } = useParams<{ serviceId: string; apiId: string }>(); + const navigate = useNavigate(); + const [api, setApi] = useState(null); + const [service, setService] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchData = useCallback(async () => { + if (!serviceId || !apiId) return; + try { + const res = await getCatalog(); + if (res.success && res.data) { + const foundService = res.data.find((s) => s.serviceId === Number(serviceId)); + if (foundService) { + setService(foundService); + const foundApi = foundService.domains + .flatMap((d) => d.apis) + .find((a) => a.apiId === Number(apiId)); + if (foundApi) { + setApi(foundApi); + } else { + setError('API를 찾을 수 없습니다'); + } + } else { + setError('서비스를 찾을 수 없습니다'); + } + } else { + setError('API 정보를 불러오지 못했습니다'); + } + } catch { + setError('API 정보를 불러오는 중 오류가 발생했습니다'); + } finally { + setIsLoading(false); + } + }, [serviceId, apiId]); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + if (isLoading) { + return ( +
+
로딩 중...
+
+ ); + } + + if (error || !api) { + return ( +
+ +
+ {error ?? 'API를 찾을 수 없습니다'} +
+
+ ); + } + + const domainLabel = api.apiDomain || '기타'; + + return ( +
+ {/* Breadcrumb */} + + + {/* Header Card */} +
+
+ + {api.apiMethod} + +
+

+ {api.apiPath} +

+

{api.apiName}

+
+ + {api.isActive ? '활성' : '비활성'} + +
+
+ +
+ {/* 기본 정보 */} +
+
+

기본 정보

+
+ + + {api.apiMethod} + + } + /> + {api.apiPath}} + /> + + {api.apiDomain} + + ) : ( + - + ) + } + /> + - + ) + } + /> + + {api.isActive ? '활성' : '비활성'} + + } + /> + +
+
+ + {/* 설명 */} +
+

설명

+ {api.description ? ( +

+ {api.description} +

+ ) : ( +

설명이 없습니다

+ )} +
+ + {/* 요청 정보 */} +
+

요청 정보

+
+ + {api.apiMethod} + + {api.apiPath} +
+

+ 상세 요청/응답 명세는 추후 제공될 예정입니다 +

+
+
+ + {/* 사이드: 서비스 정보 */} +
+
+

서비스 정보

+ {service ? ( +
+

{service.serviceName}

+

{service.serviceCode}

+ {service.description && ( +

{service.description}

+ )} +
+ + + {service.healthStatus === 'UP' ? '정상' : service.healthStatus === 'DOWN' ? '중단' : '알 수 없음'} + +
+ +
+ ) : ( +

서비스 정보를 불러올 수 없습니다

+ )} +
+
+
+
+ ); +}; + +export default ApiHubApiDetailPage; diff --git a/frontend/src/pages/apihub/ApiHubDashboardPage.tsx b/frontend/src/pages/apihub/ApiHubDashboardPage.tsx new file mode 100644 index 0000000..a174e31 --- /dev/null +++ b/frontend/src/pages/apihub/ApiHubDashboardPage.tsx @@ -0,0 +1,241 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; +import type { ServiceCatalog, RecentApi } from '../../types/apihub'; +import type { TopApi } from '../../types/dashboard'; +import { getCatalog, getRecentApis } from '../../services/apiHubService'; +import { getTopApis } from '../../services/dashboardService'; + +const METHOD_COLORS: Record = { + GET: 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300', + POST: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300', + PUT: 'bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300', + PATCH: 'bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300', + DELETE: 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300', +}; + +const HEALTH_DOT: Record = { + UP: 'bg-green-500', + DOWN: 'bg-red-500', + UNKNOWN: 'bg-gray-400', +}; + +const HEALTH_LABEL: Record = { + UP: '정상', + DOWN: '중단', + UNKNOWN: '알 수 없음', +}; + +const extractSettled = (result: PromiseSettledResult<{ data?: T }>, fallback: T): T => { + if (result.status === 'fulfilled' && result.value.data !== undefined) { + return result.value.data; + } + return fallback; +}; + +const truncate = (str: string, max: number): string => + str.length > max ? str.slice(0, max) + '...' : str; + +const formatDate = (dateStr: string): string => { + const d = new Date(dateStr); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; +}; + +const ApiHubDashboardPage = () => { + const navigate = useNavigate(); + const [catalog, setCatalog] = useState([]); + const [recentApis, setRecentApis] = useState([]); + const [topApis, setTopApis] = useState([]); + const [searchQuery, setSearchQuery] = useState(''); + const [isLoading, setIsLoading] = useState(true); + + const fetchAll = useCallback(async () => { + try { + const [catalogRes, recentRes, topRes] = await Promise.allSettled([ + getCatalog(), + getRecentApis(), + getTopApis(5), + ]); + setCatalog(extractSettled(catalogRes, [])); + setRecentApis(extractSettled(recentRes, [])); + setTopApis(extractSettled(topRes, [])); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + fetchAll(); + }, [fetchAll]); + + const filteredCatalog = catalog.filter((svc) => { + if (!searchQuery) return true; + const q = searchQuery.toLowerCase(); + return ( + svc.serviceName.toLowerCase().includes(q) || + svc.serviceCode.toLowerCase().includes(q) || + (svc.description ?? '').toLowerCase().includes(q) + ); + }); + + const filteredRecentApis = recentApis.filter((api) => { + if (!searchQuery) return true; + const q = searchQuery.toLowerCase(); + return ( + api.apiName.toLowerCase().includes(q) || + api.apiPath.toLowerCase().includes(q) || + api.serviceName.toLowerCase().includes(q) + ); + }); + + if (isLoading) { + return ( +
+
로딩 중...
+
+ ); + } + + return ( +
+ {/* Hero Banner */} +
+

S&P API HUB

+

서비스 API를 탐색하고, 명세를 확인하세요

+
+ 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" + /> +
+
+ + {/* 인기 API 섹션 */} + {topApis.length > 0 && ( +
+

인기 API

+
+ {topApis.map((api, idx) => ( +
+
+ #{idx + 1} + + {api.serviceName} + +
+

+ {api.apiName} +

+
+ {api.count.toLocaleString()} 호출 +
+
+ ))} +
+
+ )} + + {/* 최신 등록 API 섹션 */} +
+

최신 등록 API

+ {filteredRecentApis.length > 0 ? ( +
+ {filteredRecentApis.map((api) => ( +
navigate(`/api-hub/services/${api.serviceId}/apis/${api.apiId}`)} + > +
+ + {api.apiMethod} + + + {api.serviceName} + +
+

+ {api.apiName} +

+

+ {api.apiPath} +

+ {api.description && ( +

+ {truncate(api.description, 80)} +

+ )} +

{formatDate(api.createdAt)} 등록

+
+ ))} +
+ ) : ( +
+ {searchQuery ? '검색 결과가 없습니다' : '등록된 API가 없습니다'} +
+ )} +
+ + {/* 서비스 카드 섹션 */} +
+

서비스 목록

+ {filteredCatalog.length > 0 ? ( +
+ {filteredCatalog.map((svc) => ( +
navigate(`/api-hub/services/${svc.serviceId}`)} + > +
+
+

+ {svc.serviceName} +

+

{svc.serviceCode}

+
+
+
+ + {HEALTH_LABEL[svc.healthStatus] ?? svc.healthStatus} + +
+
+ {svc.description && ( +

+ {svc.description} +

+ )} +
+ API {svc.apiCount}개 + 도메인 {svc.domains.length}개 +
+
+ ))} +
+ ) : ( +
+ {searchQuery ? '검색 결과가 없습니다' : '등록된 서비스가 없습니다'} +
+ )} +
+
+ ); +}; + +export default ApiHubDashboardPage; diff --git a/frontend/src/pages/apihub/ApiHubServicePage.tsx b/frontend/src/pages/apihub/ApiHubServicePage.tsx new file mode 100644 index 0000000..8720384 --- /dev/null +++ b/frontend/src/pages/apihub/ApiHubServicePage.tsx @@ -0,0 +1,224 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import type { ServiceCatalog, ServiceApiItem } from '../../types/apihub'; +import { getCatalog } from '../../services/apiHubService'; + +const METHOD_COLORS: Record = { + GET: 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300', + POST: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300', + PUT: 'bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300', + PATCH: 'bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300', + DELETE: 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300', +}; + +const HEALTH_DOT: Record = { + UP: 'bg-green-500', + DOWN: 'bg-red-500', + UNKNOWN: 'bg-gray-400', +}; + +const HEALTH_BADGE: Record = { + UP: 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300', + DOWN: 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300', + UNKNOWN: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300', +}; + +const HEALTH_LABEL: Record = { + UP: '정상', + DOWN: '중단', + UNKNOWN: '알 수 없음', +}; + +const truncate = (str: string, max: number): string => + str.length > max ? str.slice(0, max) + '...' : str; + +interface DomainSectionProps { + domainName: string; + apis: ServiceApiItem[]; + serviceId: number; + onNavigate: (serviceId: number, apiId: number) => void; +} + +const DomainSection = ({ domainName, apis, serviceId, onNavigate }: DomainSectionProps) => ( +
+
+

{domainName}

+ + {apis.length}개 + +
+
+ + + + + + + + + + + + {apis.map((api) => ( + onNavigate(serviceId, api.apiId)} + > + + + + + + + ))} + +
메서드경로API명설명상태
+ + {api.apiMethod} + + + {api.apiPath} + + {api.apiName} + + {api.description ? truncate(api.description, 60) : -} + + {api.isActive ? ( + + ) : ( + + )} +
+
+
+); + +const ApiHubServicePage = () => { + const { serviceId } = useParams<{ serviceId: string }>(); + const navigate = useNavigate(); + const [service, setService] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchData = useCallback(async () => { + if (!serviceId) return; + try { + const res = await getCatalog(); + if (res.success && res.data) { + const found = res.data.find((s) => s.serviceId === Number(serviceId)); + if (found) { + setService(found); + } else { + setError('서비스를 찾을 수 없습니다'); + } + } else { + setError('서비스 정보를 불러오지 못했습니다'); + } + } catch { + setError('서비스 정보를 불러오는 중 오류가 발생했습니다'); + } finally { + setIsLoading(false); + } + }, [serviceId]); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + const handleApiNavigate = (svcId: number, apiId: number) => { + navigate(`/api-hub/services/${svcId}/apis/${apiId}`); + }; + + if (isLoading) { + return ( +
+
로딩 중...
+
+ ); + } + + if (error || !service) { + return ( +
+ +
+ {error ?? '서비스를 찾을 수 없습니다'} +
+
+ ); + } + + const domainsMap = new Map(); + for (const dg of service.domains) { + const key = dg.domain || '기타'; + domainsMap.set(key, dg.apis); + } + + // If any apis fall under null domain and weren't covered by domains array, show them under 기타 + const domainEntries = [...domainsMap.entries()]; + + return ( +
+ + + {/* Service Header */} +
+
+
+
+

{service.serviceName}

+ {service.serviceCode} +
+ {service.description && ( +

{service.description}

+ )} +
+
+ + + {HEALTH_LABEL[service.healthStatus] ?? service.healthStatus} + +
+ API {service.apiCount}개 + 도메인 {service.domains.length}개 +
+
+
+
+ + {/* API 목록 by Domain */} + {domainEntries.length > 0 ? ( + domainEntries.map(([domain, apis]) => ( + + )) + ) : ( +
+ 등록된 API가 없습니다 +
+ )} +
+ ); +}; + +export default ApiHubServicePage; diff --git a/frontend/src/services/apiHubService.ts b/frontend/src/services/apiHubService.ts new file mode 100644 index 0000000..088c936 --- /dev/null +++ b/frontend/src/services/apiHubService.ts @@ -0,0 +1,5 @@ +import { get } from './apiClient'; +import type { ServiceCatalog, RecentApi } from '../types/apihub'; + +export const getCatalog = () => get('/api-hub/catalog'); +export const getRecentApis = () => get('/api-hub/recent-apis'); diff --git a/frontend/src/types/apihub.ts b/frontend/src/types/apihub.ts new file mode 100644 index 0000000..27c17d3 --- /dev/null +++ b/frontend/src/types/apihub.ts @@ -0,0 +1,41 @@ +export interface DomainGroup { + domain: string; + apis: ServiceApiItem[]; +} + +export interface ServiceApiItem { + apiId: number; + serviceId: number; + apiPath: string; + apiMethod: string; + apiName: string; + apiDomain: string | null; + apiSection: string | null; + description: string | null; + isActive: boolean; + createdAt: string; + updatedAt: string; +} + +export interface ServiceCatalog { + serviceId: number; + serviceCode: string; + serviceName: string; + description: string | null; + healthStatus: 'UP' | 'DOWN' | 'UNKNOWN'; + apiCount: number; + domains: DomainGroup[]; +} + +export interface RecentApi { + apiId: number; + apiName: string; + apiPath: string; + apiMethod: string; + apiDomain: string | null; + description: string | null; + serviceId: number; + serviceCode: string; + serviceName: string; + createdAt: string; +} diff --git a/src/main/java/com/gcsc/connection/apihub/controller/ApiHubController.java b/src/main/java/com/gcsc/connection/apihub/controller/ApiHubController.java new file mode 100644 index 0000000..e7faba0 --- /dev/null +++ b/src/main/java/com/gcsc/connection/apihub/controller/ApiHubController.java @@ -0,0 +1,42 @@ +package com.gcsc.connection.apihub.controller; + +import com.gcsc.connection.apihub.dto.RecentApiResponse; +import com.gcsc.connection.apihub.dto.ServiceCatalogResponse; +import com.gcsc.connection.apihub.service.ApiHubService; +import com.gcsc.connection.common.dto.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * API Hub 카탈로그 및 최근 API 조회 컨트롤러 + */ +@RestController +@RequestMapping("/api/api-hub") +@RequiredArgsConstructor +public class ApiHubController { + + private final ApiHubService apiHubService; + + /** + * 활성 서비스와 해당 서비스의 활성 API를 도메인별로 그룹화하여 카탈로그 형태로 반환 + */ + @GetMapping("/catalog") + public ResponseEntity>> getCatalog() { + List catalog = apiHubService.getCatalog(); + return ResponseEntity.ok(ApiResponse.ok(catalog)); + } + + /** + * 최근 등록된 활성 API 상위 10건 반환 + */ + @GetMapping("/recent-apis") + public ResponseEntity>> getRecentApis() { + List recentApis = apiHubService.getRecentApis(); + return ResponseEntity.ok(ApiResponse.ok(recentApis)); + } +} diff --git a/src/main/java/com/gcsc/connection/apihub/dto/DomainGroup.java b/src/main/java/com/gcsc/connection/apihub/dto/DomainGroup.java new file mode 100644 index 0000000..fd346e2 --- /dev/null +++ b/src/main/java/com/gcsc/connection/apihub/dto/DomainGroup.java @@ -0,0 +1,11 @@ +package com.gcsc.connection.apihub.dto; + +import com.gcsc.connection.service.dto.ServiceApiResponse; + +import java.util.List; + +public record DomainGroup( + String domain, + List apis +) { +} diff --git a/src/main/java/com/gcsc/connection/apihub/dto/RecentApiResponse.java b/src/main/java/com/gcsc/connection/apihub/dto/RecentApiResponse.java new file mode 100644 index 0000000..f521a5f --- /dev/null +++ b/src/main/java/com/gcsc/connection/apihub/dto/RecentApiResponse.java @@ -0,0 +1,34 @@ +package com.gcsc.connection.apihub.dto; + +import com.gcsc.connection.service.entity.SnpServiceApi; + +import java.time.LocalDateTime; + +public record RecentApiResponse( + Long apiId, + String apiName, + String apiPath, + String apiMethod, + String apiDomain, + String description, + Long serviceId, + String serviceCode, + String serviceName, + LocalDateTime createdAt +) { + + public static RecentApiResponse from(SnpServiceApi api) { + return new RecentApiResponse( + api.getApiId(), + api.getApiName(), + api.getApiPath(), + api.getApiMethod(), + api.getApiDomain(), + api.getDescription(), + api.getService().getServiceId(), + api.getService().getServiceCode(), + api.getService().getServiceName(), + api.getCreatedAt() + ); + } +} diff --git a/src/main/java/com/gcsc/connection/apihub/dto/ServiceCatalogResponse.java b/src/main/java/com/gcsc/connection/apihub/dto/ServiceCatalogResponse.java new file mode 100644 index 0000000..036c93e --- /dev/null +++ b/src/main/java/com/gcsc/connection/apihub/dto/ServiceCatalogResponse.java @@ -0,0 +1,43 @@ +package com.gcsc.connection.apihub.dto; + +import com.gcsc.connection.service.dto.ServiceApiResponse; +import com.gcsc.connection.service.entity.SnpService; +import com.gcsc.connection.service.entity.SnpServiceApi; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +public record ServiceCatalogResponse( + Long serviceId, + String serviceCode, + String serviceName, + String description, + String healthStatus, + int apiCount, + List domains +) { + + public static ServiceCatalogResponse from(SnpService service, List apis) { + Map> byDomain = apis.stream() + .collect(Collectors.groupingBy( + api -> api.getApiDomain() != null ? api.getApiDomain() : "", + Collectors.mapping(ServiceApiResponse::from, Collectors.toList()) + )); + + List domainGroups = byDomain.entrySet().stream() + .sorted(Map.Entry.comparingByKey()) + .map(entry -> new DomainGroup(entry.getKey(), entry.getValue())) + .toList(); + + return new ServiceCatalogResponse( + service.getServiceId(), + service.getServiceCode(), + service.getServiceName(), + service.getDescription(), + service.getHealthStatus().name(), + apis.size(), + domainGroups + ); + } +} diff --git a/src/main/java/com/gcsc/connection/apihub/service/ApiHubService.java b/src/main/java/com/gcsc/connection/apihub/service/ApiHubService.java new file mode 100644 index 0000000..6f79fbb --- /dev/null +++ b/src/main/java/com/gcsc/connection/apihub/service/ApiHubService.java @@ -0,0 +1,49 @@ +package com.gcsc.connection.apihub.service; + +import com.gcsc.connection.apihub.dto.RecentApiResponse; +import com.gcsc.connection.apihub.dto.ServiceCatalogResponse; +import com.gcsc.connection.service.entity.SnpService; +import com.gcsc.connection.service.entity.SnpServiceApi; +import com.gcsc.connection.service.repository.SnpServiceApiRepository; +import com.gcsc.connection.service.repository.SnpServiceRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@Slf4j +@RequiredArgsConstructor +public class ApiHubService { + + private final SnpServiceRepository snpServiceRepository; + private final SnpServiceApiRepository snpServiceApiRepository; + + /** + * 활성 서비스와 각 서비스의 활성 API를 도메인별로 그룹화하여 카탈로그 반환 + */ + @Transactional(readOnly = true) + public List getCatalog() { + List activeServices = snpServiceRepository.findByIsActiveTrue(); + + return activeServices.stream() + .map(service -> { + List activeApis = snpServiceApiRepository + .findByServiceServiceIdAndIsActiveTrue(service.getServiceId()); + return ServiceCatalogResponse.from(service, activeApis); + }) + .toList(); + } + + /** + * 최근 등록된 활성 API 상위 10건 반환 + */ + @Transactional(readOnly = true) + public List getRecentApis() { + return snpServiceApiRepository.findTop10ByIsActiveTrueOrderByCreatedAtDesc().stream() + .map(RecentApiResponse::from) + .toList(); + } +} diff --git a/src/main/java/com/gcsc/connection/service/repository/SnpServiceApiRepository.java b/src/main/java/com/gcsc/connection/service/repository/SnpServiceApiRepository.java index f4f0025..78eea60 100644 --- a/src/main/java/com/gcsc/connection/service/repository/SnpServiceApiRepository.java +++ b/src/main/java/com/gcsc/connection/service/repository/SnpServiceApiRepository.java @@ -15,4 +15,8 @@ public interface SnpServiceApiRepository extends JpaRepository findByServiceServiceIdAndApiPathAndApiMethod( Long serviceId, String apiPath, String apiMethod); + + List findByServiceServiceIdAndIsActiveTrue(Long serviceId); + + List findTop10ByIsActiveTrueOrderByCreatedAtDesc(); } From 50b721b521d79305ecb0e364483daf82788edd3d Mon Sep 17 00:00:00 2001 From: HYOJIN Date: Mon, 13 Apr 2026 12:47:22 +0900 Subject: [PATCH 02/12] =?UTF-8?q?docs:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=85=B8=ED=8A=B8=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/RELEASE-NOTES.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 5927ecc..5daa5af 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -6,6 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/ko/1.0.0/). ## [Unreleased] +### 추가 + +- S&P API HUB SPA 대시보드 (배너, 인기 API, 최신 API, 서비스 카드) (#40) +- API Hub 서비스 트리 사이드바 레이아웃 (서비스 > 도메인 > API) (#40) +- 서비스별 API 목록 페이지 (도메인별 그룹) (#40) +- API 상세 명세 페이지 (#40) +- 백엔드 카탈로그/최신 API 조회 엔드포인트 (#40) + ## [2026-04-13] ### 추가 From a9cdf9648198a56a63f328456c554b1d702e6736 Mon Sep 17 00:00:00 2001 From: HYOJIN Date: Tue, 14 Apr 2026 13:57:09 +0900 Subject: [PATCH 03/12] =?UTF-8?q?feat(api):=20API=20=EA=B4=80=EB=A6=AC=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=ED=99=94=EB=A9=B4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - API 명세(Spec) 및 파라미터(Param) CRUD 엔드포인트 추가 - API 관리 상세 편집 페이지(ApiEditPage) 구현 - API 목록 관리 페이지(ApisPage) 구현 - 요청인자/출력결과 편집 + JSON 파싱 기능 - 프론트엔드 타입/서비스 정의 추가 Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/pages/admin/ApiEditPage.tsx | 822 ++++++++++++++++++ frontend/src/pages/admin/ApisPage.tsx | 430 +++++++++ frontend/src/pages/admin/ServicesPage.tsx | 203 +---- frontend/src/services/serviceService.ts | 37 +- frontend/src/types/service.ts | 74 ++ .../apihub/controller/ApiHubController.java | 26 +- .../global/controller/WebViewController.java | 3 +- .../service/controller/ServiceController.java | 78 ++ .../service/dto/ApiDetailResponse.java | 11 + .../service/dto/ApiParamResponse.java | 32 + .../service/dto/ApiSpecResponse.java | 42 + .../service/dto/SaveApiParamRequest.java | 13 + .../service/dto/SaveApiSpecRequest.java | 15 + .../service/dto/UpdateServiceApiRequest.java | 12 + .../service/entity/SnpServiceApiParam.java | 84 ++ .../service/entity/SnpServiceApiSpec.java | 95 ++ .../SnpServiceApiParamRepository.java | 15 + .../SnpServiceApiSpecRepository.java | 13 + .../service/ServiceManagementService.java | 169 ++++ 19 files changed, 2016 insertions(+), 158 deletions(-) create mode 100644 frontend/src/pages/admin/ApiEditPage.tsx create mode 100644 frontend/src/pages/admin/ApisPage.tsx create mode 100644 src/main/java/com/gcsc/connection/service/dto/ApiDetailResponse.java create mode 100644 src/main/java/com/gcsc/connection/service/dto/ApiParamResponse.java create mode 100644 src/main/java/com/gcsc/connection/service/dto/ApiSpecResponse.java create mode 100644 src/main/java/com/gcsc/connection/service/dto/SaveApiParamRequest.java create mode 100644 src/main/java/com/gcsc/connection/service/dto/SaveApiSpecRequest.java create mode 100644 src/main/java/com/gcsc/connection/service/dto/UpdateServiceApiRequest.java create mode 100644 src/main/java/com/gcsc/connection/service/entity/SnpServiceApiParam.java create mode 100644 src/main/java/com/gcsc/connection/service/entity/SnpServiceApiSpec.java create mode 100644 src/main/java/com/gcsc/connection/service/repository/SnpServiceApiParamRepository.java create mode 100644 src/main/java/com/gcsc/connection/service/repository/SnpServiceApiSpecRepository.java diff --git a/frontend/src/pages/admin/ApiEditPage.tsx b/frontend/src/pages/admin/ApiEditPage.tsx new file mode 100644 index 0000000..abffb11 --- /dev/null +++ b/frontend/src/pages/admin/ApiEditPage.tsx @@ -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 = { + 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(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([]); + + // 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([]); + const [responseParams, setResponseParams] = useState([]); + + 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(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)[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 ( +
+
+
+ ); + } + + // Not found + if (notFound) { + return ( +
+

API를 찾을 수 없습니다.

+ + API 목록으로 돌아가기 + +
+ ); + } + + // Error + if (error) { + return ( +
+
+ {error} +
+ + API 목록으로 돌아가기 + +
+ ); + } + + return ( +
+ {/* Breadcrumb */} + + + {/* Header */} +
+
+

{apiName}

+
+ + {apiMethod} + + {apiPath} +
+
+
+ + +
+
+ + {/* Save message */} + {saveMessage && ( +
+ {saveMessage.text} +
+ )} + + {/* Sections */} +
+ {/* Section 1: 기본 정보 */} +
+

기본 정보

+
+
+ + +
+
+ + setApiPath(e.target.value)} + placeholder="/api/v1/example" + className={`${INPUT_CLS} font-mono`} + /> +
+
+ + setApiName(e.target.value)} + placeholder="API 이름" + className={INPUT_CLS} + /> +
+
+ + +
+
+ + setApiSection(e.target.value)} + placeholder="섹션 (선택)" + className={INPUT_CLS} + /> +
+
+ setApiIsActive(e.target.checked)} + className="w-4 h-4 text-blue-600 rounded border-gray-300 dark:border-gray-600 focus:ring-blue-500" + /> + +
+
+
+ +