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] ### 추가 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(); }