Compare commits

...

8 커밋

작성자 SHA1 메시지 날짜
b37867b8ad feat(api-hub): 대시보드 개선 및 도메인 상세 페이지 구현
- 대시보드 레이아웃 개선 (히어로 배너, 도메인 카드 이미지, 인기/최신 API)
- 인기 API: 최근 1주일 기준 Top 3 (PopularApiResponse 백엔드 추가)
- 도메인 상세 페이지 (ApiHubDomainPage) 구현 + 리스트 뷰/검색
- 사이드바 도메인 클릭 시 도메인 상세 페이지 이동
- 브레드크럼: 서비스 제거, 도메인 기반으로 변경
- NoResourceFoundException 404 처리 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 15:47:06 +09:00
01fe6e62f7 feat(api-hub): API 사용 신청 모달 및 API 선택 UI 도메인 기반 변경
- API HUB 상세 화면에 API 사용 신청 모달 추가
- 모달 내 도메인 기반 체크박스 트리로 API 선택
- KeyRequestPage API 선택: 서비스 기반 → 도메인 기반 변경
- API 행에서 Path/Method 제거, API명만 표시
- 도메인 정렬순서 카탈로그(sortOrder) 기준으로 통일

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 13:59:28 +09:00
17d870c06a feat(domain): 도메인 관리 기능 및 API HUB 사이드바 개선
- SnpApiDomain 엔티티/레포/컨트롤러 (CRUD /api/domains)
- Admin 도메인 관리 페이지 (DomainsPage) - SVG 아이콘 미리보기
- API HUB 사이드바: 서비스 기반 3단 → 도메인 기반 2단 플랫 메뉴
- DB 아이콘/정렬순서 반영 (viewBox 24x24, 다중 path 지원)
- 카탈로그 DomainGroup에 iconPath/sortOrder 추가
- API 관리 도메인 입력을 셀렉트박스로 변경

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 13:59:05 +09:00
dfee04f703 feat(apikey): API Key 검토 모달 예상 요청량 수정 기능
- 검토 모달에서 예상 요청량 셀렉트박스로 수정 가능
- 승인 시 adjustedDailyRequestLimit 전달

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 13:58:40 +09:00
5ce1ca233d feat(gateway): API 인증 쿼리파라미터 변경 및 일일 요청량 제한
- API Key 인증: X-API-KEY 헤더 → authKey 쿼리 파라미터 변경
- 일일 요청량 제한 기능 (daily_request_limit, HTTP 429)
- 인증/권한 거부 로그 상태 DENIED 분리 (기존 FAIL에서 분리)
- 에러 응답에 code 필드 추가 (ApiResponse, GatewayController)
- API Key 생성/검토 시 dailyRequestLimit 설정 지원

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 13:58:18 +09:00
dd1ac022d2 feat(api-hub): API HUB 상세 화면 개선
- 요청 URL 생성 영역 아코디언 형태로 변경
- 샘플 URL 영역 추가 (기본 정보 하단)
- 출력결과 2열 레이아웃 (변수명|의미(단위)) 추가
- 공통 샘플 코드 연동

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 13:57:53 +09:00
ac0f51b816 feat(config): 시스템 공통 설정 및 샘플 코드 관리
- SnpSystemConfig 엔티티/레포/서비스/컨트롤러 구현
- GET/PUT /api/config/{configKey} 엔드포인트
- 공통 샘플 코드 관리 admin 페이지 (SampleCodePage)
- 프론트엔드 configService 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 13:57:32 +09:00
a9cdf96481 feat(api): API 관리 상세 화면 구현
- API 명세(Spec) 및 파라미터(Param) CRUD 엔드포인트 추가
- API 관리 상세 편집 페이지(ApiEditPage) 구현
- API 목록 관리 페이지(ApisPage) 구현
- 요청인자/출력결과 편집 + JSON 파싱 기능
- 프론트엔드 타입/서비스 정의 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 13:57:09 +09:00
66개의 변경된 파일4679개의 추가작업 그리고 892개의 파일을 삭제

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 KeyAdminPage from './pages/apikeys/KeyAdminPage';
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 TenantsPage from './pages/admin/TenantsPage';
import ServiceStatsPage from './pages/statistics/ServiceStatsPage';
@ -22,6 +26,7 @@ import ApiHubLayout from './layouts/ApiHubLayout';
import ApiHubDashboardPage from './pages/apihub/ApiHubDashboardPage';
import ApiHubServicePage from './pages/apihub/ApiHubServicePage';
import ApiHubApiDetailPage from './pages/apihub/ApiHubApiDetailPage';
import ApiHubDomainPage from './pages/apihub/ApiHubDomainPage';
import NotFoundPage from './pages/NotFoundPage';
import RoleGuard from './components/RoleGuard';
@ -49,12 +54,17 @@ const App = () => {
<Route path="/apikeys/request" element={<KeyRequestPage />} />
<Route path="/apikeys/admin" element={<RoleGuard allowedRoles={['ADMIN', 'MANAGER']}><KeyAdminPage /></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/tenants" element={<RoleGuard allowedRoles={['ADMIN']}><TenantsPage /></RoleGuard>} />
<Route path="*" element={<NotFoundPage />} />
</Route>
<Route element={<ApiHubLayout />}>
<Route path="/api-hub" element={<ApiHubDashboardPage />} />
<Route path="/api-hub/domains/:domainName" element={<ApiHubDomainPage />} />
<Route path="/api-hub/services/:serviceId" element={<ApiHubServicePage />} />
<Route path="/api-hub/services/:serviceId/apis/:apiId" element={<ApiHubApiDetailPage />} />
</Route>

파일 보기

@ -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 { useAuth } from '../hooks/useAuth';
import { useTheme } from '../hooks/useTheme';
@ -7,22 +7,28 @@ import type { ServiceCatalog } from '../types/apihub';
const ROLES = ['ADMIN', 'MANAGER', 'USER'] as const;
const METHOD_BADGE_CLASS: Record<string, string> = {
GET: 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300',
POST: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300',
PUT: 'bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300',
DELETE: 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300',
const DEFAULT_ICON_PATHS = ['M3.75 9.776c.112-.017.227-.026.344-.026h15.812c.117 0 .232.009.344.026m-16.5 0a2.25 2.25 0 00-1.883 2.542l.857 6a2.25 2.25 0 002.227 1.932H19.05a2.25 2.25 0 002.227-1.932l.857-6a2.25 2.25 0 00-1.883-2.542m-16.5 0V6A2.25 2.25 0 016 3.75h3.879a1.5 1.5 0 011.06.44l2.122 2.12a1.5 1.5 0 001.06.44H18A2.25 2.25 0 0120.25 9v.776'];
/** iconPath 문자열에서 SVG path d 값 배열을 추출 */
const parseIconPaths = (iconPath: string | null): string[] => {
if (!iconPath) return DEFAULT_ICON_PATHS;
// <path d="..."/> 형태에서 d 값 추출
const pathRegex = /d="([^"]+)"/g;
const matches: string[] = [];
let m;
while ((m = pathRegex.exec(iconPath)) !== null) {
matches.push(m[1]);
}
// d 태그가 없으면 단일 path d 값으로 간주
return matches.length > 0 ? matches : [iconPath];
};
const getMethodBadgeClass = (method: string): string =>
METHOD_BADGE_CLASS[method.toUpperCase()] ??
'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300';
const HEALTH_DOT_CLASS: Record<ServiceCatalog['healthStatus'], string> = {
UP: 'bg-green-500',
DOWN: 'bg-red-500',
UNKNOWN: 'bg-gray-400',
};
interface FlatDomainGroup {
domain: string;
iconPath: string | null;
sortOrder: number;
apis: { serviceId: number; apiId: number; apiName: string; apiPath: string; apiMethod: string }[];
}
const ApiHubLayout = () => {
const { user, setRole } = useAuth();
@ -32,44 +38,78 @@ const ApiHubLayout = () => {
const [catalog, setCatalog] = useState<ServiceCatalog[]>([]);
const [loading, setLoading] = useState(true);
const [openServices, setOpenServices] = useState<Record<number, boolean>>({});
const [openDomains, setOpenDomains] = useState<Record<string, boolean>>({});
const [searchQuery, setSearchQuery] = useState('');
useEffect(() => {
getCatalog()
.then((res) => {
const items = res.data ?? [];
setCatalog(items);
// Open all service groups and domain groups by default
const serviceState: Record<number, boolean> = {};
const domainState: Record<string, boolean> = {};
items.forEach((svc) => {
serviceState[svc.serviceId] = true;
svc.domains.forEach((dg) => {
domainState[`${svc.serviceId}:${dg.domain}`] = true;
});
});
setOpenServices(serviceState);
setOpenDomains(domainState);
})
.finally(() => setLoading(false));
}, []);
const toggleService = (serviceId: number) => {
setOpenServices((prev) => ({ ...prev, [serviceId]: !prev[serviceId] }));
};
const toggleDomain = (key: string) => {
setOpenDomains((prev) => ({ ...prev, [key]: !prev[key] }));
};
// 서비스 계층을 제거하고 도메인 기준으로 플랫하게 재그룹핑
const domainGroups = useMemo<FlatDomainGroup[]>(() => {
const map = new Map<string, { apis: FlatDomainGroup['apis']; iconPath: string | null; sortOrder: number }>();
for (const svc of catalog) {
for (const dg of svc.domains) {
const key = dg.domain.toUpperCase();
const existing = map.get(key);
const apis = existing?.apis ?? [];
apis.push(
...dg.apis.map((api) => ({
serviceId: svc.serviceId,
apiId: api.apiId,
apiName: api.apiName,
apiPath: api.apiPath,
apiMethod: api.apiMethod,
})),
);
// 첫 번째로 발견된 iconPath/sortOrder 사용
const iconPath = existing?.iconPath !== undefined ? existing.iconPath : (dg.iconPath ?? null);
const sortOrder = existing?.sortOrder !== undefined ? existing.sortOrder : (dg.sortOrder ?? Number.MAX_SAFE_INTEGER);
map.set(key, { apis, iconPath, sortOrder });
}
}
return Array.from(map.entries())
.map(([domain, { apis, iconPath, sortOrder }]) => ({ domain, iconPath, sortOrder, apis }))
.sort((a, b) => a.sortOrder - b.sortOrder || a.domain.localeCompare(b.domain));
}, [catalog]);
const filteredDomainGroups = useMemo(() => {
if (!searchQuery.trim()) return domainGroups;
const q = searchQuery.trim().toLowerCase();
return domainGroups
.map((dg) => {
const domainMatch = dg.domain.toLowerCase().includes(q);
const filteredApis = dg.apis.filter(
(api) => api.apiName.toLowerCase().includes(q) || api.apiPath.toLowerCase().includes(q),
);
if (domainMatch) return dg;
if (filteredApis.length > 0) return { ...dg, apis: filteredApis };
return null;
})
.filter((dg): dg is NonNullable<typeof dg> => dg !== null);
}, [domainGroups, searchQuery]);
const isSearching = searchQuery.trim().length > 0;
return (
<div className="flex min-h-screen">
{/* Sidebar */}
<aside className="fixed left-0 top-0 h-screen w-72 bg-gray-900 text-white flex flex-col">
{/* Sidebar header */}
<div className="flex-shrink-0 border-b border-gray-700">
<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
className="h-5 w-5 text-blue-400 flex-shrink-0"
fill="none"
@ -84,7 +124,7 @@ const ApiHubLayout = () => {
/>
</svg>
<span className="text-base font-bold tracking-wide text-white">S&amp;P API HUB</span>
</div>
</button>
<div className="px-5 pb-3">
<button
onClick={() => navigate('/dashboard')}
@ -98,8 +138,39 @@ const ApiHubLayout = () => {
</div>
</div>
{/* Search */}
<div className="flex-shrink-0 px-3 pt-3 pb-1">
<div className="relative">
<svg
className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-gray-400 pointer-events-none"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="API 검색..."
className="w-full bg-gray-800 border border-gray-700 text-gray-200 placeholder-gray-500 rounded-lg pl-8 pr-8 py-1.5 text-xs focus:ring-1 focus:ring-blue-500 focus:border-blue-500 focus:outline-none"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery('')}
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300"
>
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
</div>
{/* Navigation tree */}
<nav className="flex-1 overflow-y-auto px-3 py-4 space-y-1">
<nav className="flex-1 overflow-y-auto px-3 py-2 space-y-1">
{loading ? (
<div className="flex items-center justify-center py-10">
<svg
@ -122,35 +193,47 @@ const ApiHubLayout = () => {
/>
</svg>
</div>
) : filteredDomainGroups.length === 0 ? (
<p className="text-xs text-gray-500 text-center py-6"> </p>
) : (
catalog.map((service) => {
const serviceOpen = openServices[service.serviceId] ?? false;
const healthDot = HEALTH_DOT_CLASS[service.healthStatus];
filteredDomainGroups.map((dg) => {
const domainOpen = isSearching || (openDomains[dg.domain] ?? true);
return (
<div key={service.serviceId} className="mb-1">
{/* Service group header */}
<div className="flex items-center gap-1">
<div key={dg.domain}>
{/* Domain header */}
<div className="flex w-full items-center gap-1 rounded-lg text-sm font-semibold text-gray-200 hover:bg-gray-800 hover:text-white transition-colors">
{/* 도메인명 클릭 → 도메인 상세 페이지 이동 */}
<button
onClick={() => navigate(`/api-hub/services/${service.serviceId}`)}
className="flex flex-1 min-w-0 items-center gap-2 rounded-l-lg px-3 py-2 text-sm font-semibold text-gray-200 hover:bg-gray-800 hover:text-white transition-colors text-left"
>
<span
className={`h-2 w-2 flex-shrink-0 rounded-full ${healthDot}`}
title={service.healthStatus}
/>
<span className="truncate">{service.serviceName}</span>
<span className="ml-auto flex-shrink-0 rounded-full bg-gray-700 px-1.5 py-0.5 text-xs text-gray-300">
{service.apiCount}
</span>
</button>
<button
onClick={() => toggleService(service.serviceId)}
className="flex-shrink-0 rounded-r-lg p-2 text-gray-400 hover:bg-gray-800 hover:text-white transition-colors"
aria-label={serviceOpen ? 'Collapse' : 'Expand'}
onClick={() => navigate(`/api-hub/domains/${encodeURIComponent(dg.domain)}`)}
className="flex flex-1 items-center gap-2 px-3 py-2 min-w-0"
>
<svg
className={`h-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"
viewBox="0 0 24 24"
stroke="currentColor"
@ -160,63 +243,25 @@ const ApiHubLayout = () => {
</button>
</div>
{/* Domain groups */}
{serviceOpen && (
<div className="ml-3 mt-0.5 space-y-0.5">
{service.domains.map((dg) => {
const domainKey = `${service.serviceId}:${dg.domain}`;
const domainOpen = openDomains[domainKey] ?? false;
{/* API items */}
{domainOpen && (
<div className="ml-4 mt-0.5 space-y-0.5">
{dg.apis.map((api) => {
const apiPath = `/api-hub/services/${api.serviceId}/apis/${api.apiId}`;
const isActive = location.pathname === apiPath;
return (
<div key={dg.domain}>
{/* Domain header */}
<button
onClick={() => toggleDomain(domainKey)}
className="flex w-full items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs font-medium text-gray-400 hover:bg-gray-800 hover:text-gray-200 transition-colors"
>
<svg
className={`h-3 w-3 flex-shrink-0 transition-transform ${domainOpen ? 'rotate-90' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
<span className="truncate uppercase tracking-wider">{dg.domain}</span>
<span className="ml-auto flex-shrink-0 text-gray-500">
{dg.apis.length}
</span>
</button>
{/* API items */}
{domainOpen && (
<div className="ml-3 mt-0.5 space-y-0.5">
{dg.apis.map((api) => {
const apiPath = `/api-hub/services/${service.serviceId}/apis/${api.apiId}`;
const isActive = location.pathname === apiPath;
return (
<NavLink
key={api.apiId}
to={apiPath}
className={`flex items-center gap-2 rounded-lg px-2.5 py-1.5 text-xs transition-colors ${
isActive
? 'bg-gray-700 text-white'
: 'text-gray-300 hover:bg-gray-800 hover:text-white'
}`}
>
<span
className={`flex-shrink-0 rounded px-1 py-0.5 text-[10px] font-bold leading-none ${getMethodBadgeClass(api.apiMethod)}`}
>
{api.apiMethod.toUpperCase()}
</span>
<span className="truncate">{api.apiName}</span>
</NavLink>
);
})}
</div>
)}
</div>
<NavLink
key={`${api.serviceId}-${api.apiId}`}
to={apiPath}
className={`block rounded-lg px-2.5 py-1.5 text-xs truncate transition-colors ${
isActive
? 'bg-gray-700 text-white'
: 'text-gray-300 hover:bg-gray-800 hover:text-white'
}`}
>
{api.apiName}
</NavLink>
);
})}
</div>

파일 보기

@ -40,6 +40,9 @@ const navGroups: NavGroup[] = [
adminOnly: true,
items: [
{ 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: '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 { useNavigate } from 'react-router-dom';
import type {
ServiceInfo,
ServiceApi,
CreateServiceRequest,
UpdateServiceRequest,
CreateServiceApiRequest,
} from '../../types/service';
import {
getServices,
createService,
updateService,
deleteService,
getServiceApis,
createServiceApi,
} from '../../services/serviceService';
const HEALTH_BADGE: Record<string, { dot: string; bg: string; text: string }> = {
@ -41,6 +41,7 @@ const formatRelativeTime = (dateStr: string | null): string => {
};
const ServicesPage = () => {
const navigate = useNavigate();
const [services, setServices] = useState<ServiceInfo[]>([]);
const [selectedService, setSelectedService] = useState<ServiceInfo | null>(null);
const [serviceApis, setServiceApis] = useState<ServiceApi[]>([]);
@ -57,14 +58,6 @@ const ServicesPage = () => {
const [healthCheckInterval, setHealthCheckInterval] = useState(60);
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 () => {
try {
setLoading(true);
@ -97,8 +90,13 @@ const ServicesPage = () => {
}, []);
const handleSelectService = (service: ServiceInfo) => {
setSelectedService(service);
fetchApis(service.serviceId);
if (selectedService?.serviceId === service.serviceId) {
setSelectedService(null);
setServiceApis([]);
} else {
setSelectedService(service);
fetchApis(service.serviceId);
}
};
const handleOpenCreateService = () => {
@ -172,44 +170,21 @@ const ServicesPage = () => {
}
};
const handleOpenCreateApi = () => {
setApiMethod('GET');
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);
const handleDeleteService = async (service: ServiceInfo) => {
if (!window.confirm(`'${service.serviceName}' 서비스를 삭제하시겠습니까?`)) return;
try {
const req: CreateServiceApiRequest = {
apiMethod,
apiPath,
apiName,
apiDomain: apiDomain || undefined,
apiSection: apiSection || undefined,
description: apiDescription || undefined,
};
const res = await createServiceApi(selectedService.serviceId, req);
const res = await deleteService(service.serviceId);
if (!res.success) {
setError(res.message || 'API 생성에 실패했습니다.');
setError(res.message || '서비스 삭제에 실패했습니다.');
return;
}
handleCloseApiModal();
await fetchApis(selectedService.serviceId);
if (selectedService?.serviceId === service.serviceId) {
setSelectedService(null);
setServiceApis([]);
}
await fetchServices();
} catch {
setError('API 생성에 실패했습니다.');
setError('서비스 삭제에 실패했습니다.');
}
};
@ -229,7 +204,7 @@ const ServicesPage = () => {
</button>
</div>
{error && !isServiceModalOpen && !isApiModalOpen && (
{error && !isServiceModalOpen && (
<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>
</td>
<td className="px-4 py-3">
<button
onClick={(e) => {
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"
>
</button>
<div className="flex items-center gap-2">
<button
onClick={(e) => {
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"
>
</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>
</tr>
);
@ -323,10 +309,10 @@ const ServicesPage = () => {
APIs for {selectedService.serviceName}
</h2>
<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"
>
Add API
API
</button>
</div>
<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">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">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">Active</th>
</tr>
@ -354,6 +342,8 @@ const ServicesPage = () => {
</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-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">
<span
@ -370,7 +360,7 @@ const ServicesPage = () => {
))}
{serviceApis.length === 0 && (
<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가 .
</td>
</tr>
@ -499,103 +489,6 @@ const ServicesPage = () => {
</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>
);
};

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다. 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 type { ServiceCatalog, RecentApi } from '../../types/apihub';
import type { TopApi } from '../../types/dashboard';
import { getCatalog, getRecentApis } from '../../services/apiHubService';
import { getTopApis } from '../../services/dashboardService';
import type { RecentApi, PopularApi, ServiceCatalog } from '../../types/apihub';
import { getRecentApis, getPopularApis, getCatalog } from '../../services/apiHubService';
const METHOD_COLORS: Record<string, string> = {
GET: 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300',
POST: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300',
PUT: 'bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300',
PATCH: 'bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300',
DELETE: 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300',
const formatDomain = (d: string) => (/^[a-zA-Z\s\-_]+$/.test(d) ? d.toUpperCase() : d);
// 도메인 컬러 팔레트 (해시 기반 매핑)
const DOMAIN_COLOR_PALETTE = [
{ color: 'text-emerald-400', bg: 'bg-emerald-500/10', border: 'border-emerald-500/30', line: 'from-emerald-500' },
{ color: 'text-rose-400', bg: 'bg-rose-500/10', border: 'border-rose-500/30', line: 'from-rose-500' },
{ color: 'text-blue-400', bg: 'bg-blue-500/10', border: 'border-blue-500/30', line: 'from-blue-500' },
{ color: 'text-amber-400', bg: 'bg-amber-500/10', border: 'border-amber-500/30', line: 'from-amber-500' },
{ color: 'text-violet-400', bg: 'bg-violet-500/10', border: 'border-violet-500/30', line: 'from-violet-500' },
{ color: 'text-cyan-400', bg: 'bg-cyan-500/10', border: 'border-cyan-500/30', line: 'from-cyan-500' },
{ color: 'text-orange-400', bg: 'bg-orange-500/10', border: 'border-orange-500/30', line: 'from-orange-500' },
{ color: 'text-pink-400', bg: 'bg-pink-500/10', border: 'border-pink-500/30', line: 'from-pink-500' },
{ color: 'text-lime-400', bg: 'bg-lime-500/10', border: 'border-lime-500/30', line: 'from-lime-500' },
{ color: 'text-indigo-400', bg: 'bg-indigo-500/10', border: 'border-indigo-500/30', line: 'from-indigo-500' },
{ color: 'text-teal-400', bg: 'bg-teal-500/10', border: 'border-teal-500/30', line: 'from-teal-500' },
{ color: 'text-fuchsia-400', bg: 'bg-fuchsia-500/10', border: 'border-fuchsia-500/30', line: 'from-fuchsia-500' },
];
const DEFAULT_ICON_PATHS = [
'M3.75 9.776c.112-.017.227-.026.344-.026h15.812c.117 0 .232.009.344.026m-16.5 0a2.25 2.25 0 00-1.883 2.542l.857 6a2.25 2.25 0 002.227 1.932H19.05a2.25 2.25 0 002.227-1.932l.857-6a2.25 2.25 0 00-1.883-2.542m-16.5 0V6A2.25 2.25 0 016 3.75h3.879a1.5 1.5 0 011.06.44l2.122 2.12a1.5 1.5 0 001.06.44H18A2.25 2.25 0 0120.25 9v.776',
];
/** iconPath 문자열에서 SVG path d 값 배열을 추출 */
const parseIconPaths = (iconPath: string | null): string[] => {
if (!iconPath) return DEFAULT_ICON_PATHS;
const pathRegex = /d="([^"]+)"/g;
const matches: string[] = [];
let m;
while ((m = pathRegex.exec(iconPath)) !== null) {
matches.push(m[1]);
}
return matches.length > 0 ? matches : [iconPath];
};
const HEALTH_DOT: Record<string, string> = {
UP: 'bg-green-500',
DOWN: 'bg-red-500',
UNKNOWN: 'bg-gray-400',
const domainColorCache = new Map<string, (typeof DOMAIN_COLOR_PALETTE)[0]>();
let nextColorIdx = 0;
const getDomainColorByHash = (domain: string) => {
const key = domain.toUpperCase();
const cached = domainColorCache.get(key);
if (cached) return cached;
const color = DOMAIN_COLOR_PALETTE[nextColorIdx % DOMAIN_COLOR_PALETTE.length];
nextColorIdx++;
domainColorCache.set(key, color);
return color;
};
const HEALTH_LABEL: Record<string, string> = {
UP: '정상',
DOWN: '중단',
UNKNOWN: '알 수 없음',
};
const RANK_BADGE_STYLES = [
'bg-gradient-to-br from-yellow-400 to-amber-500',
'bg-gradient-to-br from-gray-300 to-gray-400',
'bg-gradient-to-br from-amber-600 to-amber-700',
];
const extractSettled = <T,>(result: PromiseSettledResult<{ data?: T }>, fallback: T): T => {
if (result.status === 'fulfilled' && result.value.data !== undefined) {
@ -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')}`;
};
interface FlatDomain {
domain: string;
iconPath: string | null;
sortOrder: number;
apiCount: number;
}
const ApiHubDashboardPage = () => {
const navigate = useNavigate();
const [catalog, setCatalog] = useState<ServiceCatalog[]>([]);
const [recentApis, setRecentApis] = useState<RecentApi[]>([]);
const [topApis, setTopApis] = useState<TopApi[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const [popularApis, setPopularApis] = useState<PopularApi[]>([]);
const [isLoading, setIsLoading] = useState(true);
const fetchAll = useCallback(async () => {
try {
const [catalogRes, recentRes, topRes] = await Promise.allSettled([
const [catalogRes, recentRes, popularRes] = await Promise.allSettled([
getCatalog(),
getRecentApis(),
getTopApis(5),
getPopularApis(),
]);
setCatalog(extractSettled<ServiceCatalog[]>(catalogRes, []));
setRecentApis(extractSettled<RecentApi[]>(recentRes, []));
setTopApis(extractSettled<TopApi[]>(topRes, []));
setPopularApis(extractSettled<PopularApi[]>(popularRes, []));
} finally {
setIsLoading(false);
}
@ -67,25 +104,26 @@ const ApiHubDashboardPage = () => {
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 domainList = useMemo<FlatDomain[]>(() => {
const map = new Map<string, { iconPath: string | null; sortOrder: number; apiCount: number }>();
for (const svc of catalog) {
for (const dg of svc.domains) {
const key = dg.domain.toUpperCase();
const existing = map.get(key);
const apiCount = (existing?.apiCount ?? 0) + dg.apis.length;
const iconPath = existing?.iconPath !== undefined ? existing.iconPath : (dg.iconPath ?? null);
const sortOrder =
existing?.sortOrder !== undefined ? existing.sortOrder : (dg.sortOrder ?? Number.MAX_SAFE_INTEGER);
map.set(key, { iconPath, sortOrder, apiCount });
}
}
return Array.from(map.entries())
.map(([domain, { iconPath, sortOrder, apiCount }]) => ({ domain, iconPath, sortOrder, apiCount }))
.sort((a, b) => a.sortOrder - b.sortOrder || a.domain.localeCompare(b.domain));
}, [catalog]);
const 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)
);
});
const recentTop3 = recentApis.slice(0, 3);
if (isLoading) {
return (
@ -96,144 +134,210 @@ const ApiHubDashboardPage = () => {
}
return (
<div>
{/* Hero Banner */}
<div className="rounded-xl bg-gradient-to-r from-blue-600 to-indigo-700 dark:from-blue-800 dark:to-indigo-900 p-8 mb-8">
<h1 className="text-3xl font-bold text-white mb-2">S&amp;P API HUB</h1>
<p className="text-blue-100 mb-6"> API를 , </p>
<div className="max-w-xl">
<input
type="text"
placeholder="서비스명, API명, 경로 검색..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full px-4 py-3 rounded-lg text-gray-900 bg-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-300 shadow"
/>
</div>
<div className="max-w-7xl mx-auto space-y-8">
{/* 히어로 배너 */}
<div className="relative overflow-hidden rounded-2xl bg-gradient-to-br from-indigo-950 via-indigo-800 to-indigo-600 p-8">
{/* 장식 글로우 원 */}
<div className="pointer-events-none absolute -right-16 -top-16 h-64 w-64 rounded-full bg-indigo-400 opacity-20 blur-3xl" />
<div className="pointer-events-none absolute right-32 -top-8 h-32 w-32 rounded-full bg-purple-400 opacity-10 blur-2xl" />
{/* 제목 */}
<h1 className="mb-2 text-4xl font-extrabold tracking-tight text-white">S&amp;P API HUB</h1>
<p className="text-indigo-200">S&amp;P / .</p>
</div>
{/* 인기 API 섹션 */}
{topApis.length > 0 && (
<div className="mb-8">
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4"> API</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-4">
{topApis.map((api, idx) => (
<div
key={idx}
className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 border border-gray-100 dark:border-gray-700"
>
<div className="flex items-center gap-2 mb-2">
<span className="text-xs font-bold text-gray-400 dark:text-gray-500">#{idx + 1}</span>
<span className="text-xs px-2 py-0.5 rounded-full bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300 font-medium truncate">
{api.serviceName}
</span>
{popularApis.length > 0 && (
<div>
<div className="mb-4 flex items-center gap-2">
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-amber-500/20">
<svg className="h-4 w-4 text-amber-400" viewBox="0 0 24 24" fill="currentColor">
<path fillRule="evenodd" d="M12.963 2.286a.75.75 0 00-1.071-.136 9.742 9.742 0 00-3.539 6.176 7.547 7.547 0 01-1.705-1.715.75.75 0 00-1.152-.082A9 9 0 1015.68 4.534a7.46 7.46 0 01-2.717-2.248zM15.75 14.25a3.75 3.75 0 11-7.313-1.172c.628.465 1.35.81 2.133 1a5.99 5.99 0 011.925-3.546 3.75 3.75 0 013.255 3.718z" clipRule="evenodd" />
</svg>
</div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100"> API</h2>
<span className="ml-1 text-xs text-gray-400 dark:text-gray-500"> 7 </span>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
{popularApis.map((api, idx) => {
const palette = api.domain ? getDomainColorByHash(api.domain) : DOMAIN_COLOR_PALETTE[4];
return (
<div
key={idx}
className="group flex flex-col rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 shadow-sm cursor-pointer transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md hover:border-indigo-400/50 dark:hover:border-indigo-500/50"
onClick={() =>
api.serviceId && api.apiId
? navigate(`/api-hub/services/${api.serviceId}/apis/${api.apiId}`)
: undefined
}
>
<div className="mb-3 flex items-center gap-2">
{/* 랭킹 뱃지 */}
<div
className={`flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-lg text-xs font-bold text-white shadow ${RANK_BADGE_STYLES[idx] ?? 'bg-gray-500'}`}
>
{idx + 1}
</div>
{api.domain && (
<span
className={`rounded-full px-2 py-0.5 text-xs font-medium truncate ${palette.bg} ${palette.color}`}
>
{formatDomain(api.domain)}
</span>
)}
</div>
<p
className="flex-1 text-sm font-semibold text-gray-900 dark:text-gray-100 truncate mb-4"
title={api.apiName}
>
{api.apiName}
</p>
<div className="flex items-end justify-between border-t border-gray-100 dark:border-gray-700 pt-3">
<div>
<p className="text-xs text-gray-400 dark:text-gray-500 mb-0.5"> </p>
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
{api.count.toLocaleString()}
<span className="ml-0.5 text-xs font-normal text-gray-400 dark:text-gray-500"></span>
</p>
</div>
<svg className="h-7 w-10 text-indigo-400/60" fill="none" viewBox="0 0 40 28" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<polyline points="2,22 8,16 14,20 22,8 28,14 36,4" />
</svg>
</div>
</div>
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100 truncate mb-3" title={api.apiName}>
{api.apiName}
</p>
<div className="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
<span>{api.count.toLocaleString()} </span>
</div>
</div>
))}
);
})}
</div>
</div>
)}
{/* 최신 등록 API 섹션 */}
<div className="mb-8">
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4"> API</h2>
{filteredRecentApis.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredRecentApis.map((api) => (
<div
key={api.apiId}
className="bg-white dark:bg-gray-800 rounded-lg shadow p-5 border border-gray-100 dark:border-gray-700 cursor-pointer hover:shadow-md hover:border-blue-300 dark:hover:border-blue-600 transition-all"
onClick={() => navigate(`/api-hub/services/${api.serviceId}/apis/${api.apiId}`)}
>
<div className="flex items-center gap-2 mb-2">
<span
className={`px-2 py-0.5 rounded text-xs font-bold uppercase ${METHOD_COLORS[api.apiMethod] ?? 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300'}`}
>
{api.apiMethod}
</span>
<span className="text-xs px-2 py-0.5 rounded-full bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300 font-medium">
{api.serviceName}
</span>
</div>
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-1 truncate" title={api.apiName}>
{api.apiName}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400 font-mono mb-2 truncate" title={api.apiPath}>
{api.apiPath}
</p>
{api.description && (
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3 line-clamp-2">
{truncate(api.description, 80)}
</p>
)}
<p className="text-xs text-gray-400 dark:text-gray-500">{formatDate(api.createdAt)} </p>
</div>
))}
</div>
) : (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-8 text-center text-gray-400 dark:text-gray-500">
{searchQuery ? '검색 결과가 없습니다' : '등록된 API가 없습니다'}
</div>
)}
</div>
{/* 서비스 카드 섹션 */}
<div>
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4"> </h2>
{filteredCatalog.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredCatalog.map((svc) => (
<div
key={svc.serviceId}
className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 border border-gray-100 dark:border-gray-700 cursor-pointer hover:shadow-md hover:border-blue-300 dark:hover:border-blue-600 transition-all"
onClick={() => navigate(`/api-hub/services/${svc.serviceId}`)}
>
<div className="flex items-start justify-between mb-3">
<div className="flex-1 min-w-0">
<h3 className="text-base font-semibold text-gray-900 dark:text-gray-100 truncate">
{svc.serviceName}
</h3>
<p className="text-xs text-gray-500 dark:text-gray-400 font-mono">{svc.serviceCode}</p>
<div className="mb-4 flex items-center gap-2">
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-cyan-500/20">
<svg className="h-4 w-4 text-cyan-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.455 2.456L21.75 6l-1.036.259a3.375 3.375 0 00-2.455 2.456z" />
</svg>
</div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100"> API</h2>
</div>
{recentTop3.length > 0 ? (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
{recentTop3.map((api) => {
const palette = api.apiDomain ? getDomainColorByHash(api.apiDomain) : DOMAIN_COLOR_PALETTE[4];
return (
<div
key={api.apiId}
className="group flex flex-col rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 shadow-sm cursor-pointer transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md hover:border-indigo-400/50 dark:hover:border-indigo-500/50"
onClick={() => navigate(`/api-hub/services/${api.serviceId}/apis/${api.apiId}`)}
>
<div className="mb-2 flex items-center gap-2">
{api.apiDomain && (
<span
className={`rounded-full px-2 py-0.5 text-xs font-medium truncate ${palette.bg} ${palette.color}`}
>
{formatDomain(api.apiDomain)}
</span>
)}
</div>
<div className="flex items-center gap-1.5 ml-3 shrink-0">
<div className={`w-2.5 h-2.5 rounded-full ${HEALTH_DOT[svc.healthStatus] ?? 'bg-gray-400'}`} />
<span
className={`text-xs font-medium ${
svc.healthStatus === 'UP'
? 'text-green-600 dark:text-green-400'
: svc.healthStatus === 'DOWN'
? 'text-red-600 dark:text-red-400'
: 'text-gray-500 dark:text-gray-400'
}`}
>
{HEALTH_LABEL[svc.healthStatus] ?? svc.healthStatus}
</span>
</div>
</div>
{svc.description && (
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4 line-clamp-2">
{svc.description}
<p
className="flex-1 text-sm font-semibold text-gray-900 dark:text-gray-100 mb-1 truncate"
title={api.apiName}
>
{api.apiName}
</p>
)}
<div className="flex items-center gap-4 text-xs text-gray-500 dark:text-gray-400">
<span>API {svc.apiCount}</span>
<span> {svc.domains.length}</span>
{api.description && (
<p className="mb-3 text-xs text-gray-500 dark:text-gray-400 line-clamp-2">
{truncate(api.description, 80)}
</p>
)}
<div className="mt-auto flex items-center gap-1.5 text-xs text-gray-400 dark:text-gray-500">
<svg className="h-3.5 w-3.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
{formatDate(api.createdAt)}
</div>
</div>
</div>
))}
);
})}
</div>
) : (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-8 text-center text-gray-400 dark:text-gray-500">
{searchQuery ? '검색 결과가 없습니다' : '등록된 서비스가 없습니다'}
<div className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-8 text-center text-sm text-gray-400 dark:text-gray-500">
API가
</div>
)}
</div>
{/* 서비스 도메인 섹션 */}
{domainList.length > 0 && (
<div>
<div className="mb-4 flex items-center gap-2">
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-indigo-500/20">
<svg className="h-4 w-4 text-indigo-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"
/>
</svg>
</div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100"> </h2>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{domainList.map((item) => {
const palette = getDomainColorByHash(item.domain);
const iconPaths = parseIconPaths(item.iconPath);
const imgSrc = `${import.meta.env.BASE_URL}images/domains/${item.domain.toLowerCase()}.jpg`;
return (
<div
key={item.domain}
onClick={() => navigate(`/api-hub/domains/${encodeURIComponent(item.domain)}`)}
className={`group relative overflow-hidden rounded-xl border bg-white dark:bg-gray-800 ${palette.border} cursor-pointer transition-all duration-200 hover:-translate-y-1 hover:shadow-xl`}
>
<div className="relative h-[200px] overflow-hidden bg-gray-100 dark:bg-gray-700">
<img
src={imgSrc}
alt={item.domain}
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
/>
<div className="absolute inset-x-0 bottom-0 flex items-center justify-between bg-black/20 backdrop-blur-sm px-3.5 py-2.5">
<div className="flex items-center gap-2 min-w-0">
<div className={`flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-lg ${palette.bg}`}>
<svg
className={`h-4 w-4 ${palette.color}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
>
{iconPaths.map((d, i) => (
<path key={i} d={d} />
))}
</svg>
</div>
<span className="text-sm font-semibold text-white truncate">
{formatDomain(item.domain)}
</span>
</div>
<span className="flex-shrink-0 text-xs font-semibold text-white/80">
{item.apiCount} APIs
</span>
</div>
</div>
</div>
);
})}
</div>
</div>
)}
</div>
);
};

파일 보기

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

파일 보기

@ -75,6 +75,7 @@ const KeyAdminPage = () => {
const [reviewComment, setReviewComment] = useState('');
const [adjustedFromDate, setAdjustedFromDate] = useState('');
const [adjustedToDate, setAdjustedToDate] = useState('');
const [adjustedDailyLimit, setAdjustedDailyLimit] = useState('');
const [activeReviewTab, setActiveReviewTab] = useState<'info' | 'apis'>('info');
const [showRejectConfirm, setShowRejectConfirm] = useState(false);
const [showApproveConfirm, setShowApproveConfirm] = useState(false);
@ -169,6 +170,7 @@ const KeyAdminPage = () => {
setReviewComment(req.reviewComment || '');
setAdjustedFromDate(req.usageFromDate ? req.usageFromDate.split('T')[0] : '');
setAdjustedToDate(req.usageToDate ? req.usageToDate.split('T')[0] : '');
setAdjustedDailyLimit(req.dailyRequestEstimate != null ? String(req.dailyRequestEstimate) : '');
setActiveReviewTab('info');
setShowRejectConfirm(false);
setShowApproveConfirm(false);
@ -185,6 +187,7 @@ const KeyAdminPage = () => {
setReviewComment('');
setAdjustedFromDate(req.usageFromDate ? req.usageFromDate.split('T')[0] : '');
setAdjustedToDate(req.usageToDate ? req.usageToDate.split('T')[0] : '');
setAdjustedDailyLimit(req.dailyRequestEstimate != null ? String(req.dailyRequestEstimate) : '');
setActiveReviewTab('info');
setShowRejectConfirm(false);
setShowApproveConfirm(false);
@ -217,6 +220,7 @@ const KeyAdminPage = () => {
adjustedApiIds: status === 'APPROVED' ? Array.from(adjustedApiIds) : undefined,
adjustedFromDate: status === 'APPROVED' && adjustedFromDate ? adjustedFromDate : undefined,
adjustedToDate: status === 'APPROVED' && adjustedToDate ? adjustedToDate : undefined,
adjustedDailyRequestLimit: status === 'APPROVED' && adjustedDailyLimit ? Number(adjustedDailyLimit) : undefined,
});
if (res.success) {
@ -896,11 +900,29 @@ const KeyAdminPage = () => {
</div>
<span className="text-xs text-gray-500 dark:text-gray-500"> </span>
</div>
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100 pl-10">
{selectedRequest.dailyRequestEstimate != null
? `${Number(selectedRequest.dailyRequestEstimate).toLocaleString()}건/일`
: '-'}
</p>
{isReviewReadOnly ? (
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100 pl-10">
{selectedRequest.dailyRequestEstimate != null
? `${Number(selectedRequest.dailyRequestEstimate).toLocaleString()}건/일`
: '-'}
</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>
</div>
</div>

파일 보기

@ -1,16 +1,8 @@
import { useState, useEffect, useMemo, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import type { ServiceInfo, ServiceApi } from '../../types/service';
import { getServices, getServiceApis } from '../../services/serviceService';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { getCatalog } from '../../services/apiHubService';
import { createKeyRequest } from '../../services/apiKeyService';
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',
};
import type { ServiceCatalog } from '../../types/apihub';
const IndeterminateCheckbox = ({ checked, indeterminate, onChange, className }: { checked: boolean; indeterminate: boolean; onChange: () => void; className?: string }) => {
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'} />;
};
interface DomainGroup {
domain: string;
apis: ServiceApi[];
interface FlatApi {
apiId: number;
apiName: string;
description: string | null;
}
const groupApisByDomain = (apis: ServiceApi[]): DomainGroup[] => {
const domainMap = new Map<string, ServiceApi[]>();
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;
};
interface FlatDomainGroup {
domain: string;
apis: FlatApi[];
}
const KeyRequestPage = () => {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const preApiId = searchParams.get('apiId');
const [services, setServices] = useState<ServiceInfo[]>([]);
const [serviceApisMap, setServiceApisMap] = useState<Record<number, ServiceApi[]>>({});
const [expandedServices, setExpandedServices] = useState<Set<number>>(new Set());
const [catalog, setCatalog] = useState<ServiceCatalog[]>([]);
const [expandedDomains, setExpandedDomains] = useState<Set<string>>(new Set());
const [selectedApiIds, setSelectedApiIds] = useState<Set<number>>(new Set());
const [keyName, setKeyName] = useState('');
@ -71,61 +50,95 @@ const KeyRequestPage = () => {
const fetchData = async () => {
try {
setIsLoading(true);
const servicesRes = await getServices();
if (servicesRes.success && servicesRes.data) {
const activeServices = servicesRes.data.filter((s) => s.isActive);
setServices(activeServices);
const catalogRes = await getCatalog();
if (catalogRes.success && catalogRes.data) {
setCatalog(catalogRes.data);
const apisMap: Record<number, ServiceApi[]> = {};
await Promise.all(
activeServices.map(async (service) => {
const apisRes = await getServiceApis(service.serviceId);
if (apisRes.success && apisRes.data) {
apisMap[service.serviceId] = apisRes.data.filter((a) => a.isActive);
// 쿼리 파라미터로 전달된 API 자동 선택
if (preApiId) {
const aId = Number(preApiId);
for (const service of catalogRes.data) {
for (const domainGroup of service.domains) {
const targetApi = domainGroup.apis.find((a) => a.apiId === aId);
if (targetApi) {
setSelectedApiIds(new Set([aId]));
setExpandedDomains(new Set([domainGroup.domain]));
break;
}
}
}),
);
setServiceApisMap(apisMap);
}
}
} else {
setError(servicesRes.message || '서비스 목록을 불러오는데 실패했습니다.');
setError(catalogRes.message || '카탈로그를 불러오는데 실패했습니다.');
}
} catch {
setError('서비스 목록을 불러오는데 실패했습니다.');
setError('카탈로그를 불러오는데 실패했습니다.');
} finally {
setIsLoading(false);
}
};
fetchData();
}, []);
}, [preApiId]);
const groupedApisMap = useMemo(() => {
const result: Record<number, DomainGroup[]> = {};
Object.entries(serviceApisMap).forEach(([serviceId, apis]) => {
result[Number(serviceId)] = groupApisByDomain(apis);
});
return result;
}, [serviceApisMap]);
// catalog → FlatDomainGroup[] 변환 (도메인 기준 플랫 그룹핑, 알파벳순 정렬)
const flatDomainGroups = useMemo<FlatDomainGroup[]>(() => {
const domainMap = new Map<string, FlatApi[]>();
const handleToggleService = (serviceId: number) => {
setExpandedServices((prev) => {
const next = new Set(prev);
if (next.has(serviceId)) {
next.delete(serviceId);
} else {
next.add(serviceId);
for (const service of catalog) {
for (const domainGroup of service.domains) {
const domainName = domainGroup.domain || '미분류';
if (!domainMap.has(domainName)) {
domainMap.set(domainName, []);
}
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) => {
const next = new Set(prev);
if (next.has(key)) {
next.delete(key);
if (next.has(domain)) {
next.delete(domain);
} else {
next.add(key);
next.add(domain);
}
return next;
});
@ -143,26 +156,8 @@ const KeyRequestPage = () => {
});
};
const handleToggleAllServiceApis = (serviceId: number) => {
const apis = serviceApisMap[serviceId] || [];
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);
const handleToggleAllDomainApis = (domain: string) => {
const domainGroup = flatDomainGroups.find((dg) => dg.domain === domain);
if (!domainGroup) return;
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 = () => {
if (allApisSelected) {
setSelectedApiIds(new Set());
@ -202,28 +188,6 @@ const KeyRequestPage = () => {
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 from = new Date();
const to = new Date();
@ -480,42 +444,39 @@ const KeyRequestPage = () => {
</div>
</div>
{/* Service cards */}
{/* Domain cards */}
<div className="p-4 space-y-3">
{services.map((service) => {
const apis = serviceApisMap[service.serviceId] || [];
const domainGroups = filteredGroupedApisMap[service.serviceId] || [];
const isServiceExpanded = expandedServices.has(service.serviceId);
const selectedCount = apis.filter((a) => selectedApiIds.has(a.apiId)).length;
const allServiceSelected = apis.length > 0 && apis.every((a) => selectedApiIds.has(a.apiId));
const someServiceSelected = !allServiceSelected && apis.some((a) => selectedApiIds.has(a.apiId));
{filteredDomainGroups.map((domainGroup) => {
const isDomainExpanded = expandedDomains.has(domainGroup.domain);
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));
const selectedCount = domainApis.filter((a) => selectedApiIds.has(a.apiId)).length;
const hasSelections = selectedCount > 0;
if (searchQuery.trim() && domainGroups.length === 0) return null;
return (
<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'}`}
>
{/* Service header */}
{/* Domain header */}
<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'}`}
onClick={() => handleToggleService(service.serviceId)}
onClick={() => handleToggleDomain(domainGroup.domain)}
>
<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" />
</svg>
<label className="flex items-center cursor-pointer" onClick={(e) => e.stopPropagation()}>
<IndeterminateCheckbox
checked={allServiceSelected}
indeterminate={someServiceSelected}
onChange={() => handleToggleAllServiceApis(service.serviceId)}
checked={allDomainSelected}
indeterminate={someDomainSelected}
onChange={() => handleToggleAllDomainApis(domainGroup.domain)}
className="rounded"
/>
</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 && (
<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
@ -523,98 +484,44 @@ const KeyRequestPage = () => {
)}
</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">
{apis.length} API
{domainApis.length} API
</span>
</div>
{/* Service body */}
{isServiceExpanded && (
<div className="px-4 py-3 space-y-2 bg-white dark:bg-gray-900">
{domainGroups.map((domainGroup) => {
const domainKey = `${service.serviceId}-${domainGroup.domain}`;
const isDomainExpanded = expandedDomains.has(domainKey);
const domainApis = domainGroup.apis;
const allDomainSelected = domainApis.length > 0 && domainApis.every((a) => selectedApiIds.has(a.apiId));
const someDomainSelected = !allDomainSelected && domainApis.some((a) => selectedApiIds.has(a.apiId));
{/* API list */}
{isDomainExpanded && (
<div className="divide-y divide-gray-100 dark:divide-gray-700/50 bg-white dark:bg-gray-900">
{domainApis.map((api) => {
const isSelected = selectedApiIds.has(api.apiId);
return (
<div key={domainKey}>
{/* Domain row */}
<div
className="ml-5 flex items-center gap-2.5 px-3 py-2 cursor-pointer rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50"
onClick={() => handleToggleDomain(domainKey)}
>
<svg className={`h-3.5 w-3.5 text-gray-400 transition-transform ${isDomainExpanded ? 'rotate-90' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
<label className="flex items-center cursor-pointer" onClick={(e) => e.stopPropagation()}>
<IndeterminateCheckbox
checked={allDomainSelected}
indeterminate={someDomainSelected}
onChange={() => handleToggleAllDomainApis(service.serviceId, domainGroup.domain)}
className="rounded"
/>
</label>
<span className="text-sm font-semibold text-gray-800 dark:text-gray-200">{domainGroup.domain}</span>
<span className="text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 px-2 py-0.5 rounded-full">
{domainApis.length}
</span>
<div
key={api.apiId}
className={`flex items-start gap-3 px-5 py-3 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700/30 ${isSelected ? 'bg-blue-50/50 dark:bg-blue-900/10' : ''}`}
onClick={() => handleToggleApi(api.apiId)}
>
<div className="flex items-center pt-0.5">
<input
type="checkbox"
checked={isSelected}
onChange={() => handleToggleApi(api.apiId)}
onClick={(e) => e.stopPropagation()}
className="rounded"
/>
</div>
<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>
{/* 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>
);
})}
{apis.length === 0 && (
<p className="text-sm text-gray-400 dark:text-gray-500 py-3 ml-5"> API가 .</p>
)}
</div>
)}
</div>
);
})}
{services.length === 0 && (
{filteredDomainGroups.length === 0 && (
<div className="px-6 py-8 text-center text-gray-400 dark:text-gray-500">
.
{searchQuery.trim() ? '검색 결과가 없습니다.' : '등록된 API가 없습니다.'}
</div>
)}
</div>

파일 보기

@ -1,5 +1,11 @@
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 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 {
ServiceInfo,
ServiceApi,
CreateServiceRequest,
UpdateServiceRequest,
CreateServiceApiRequest,
UpdateServiceApiRequest,
ApiDetailInfo,
SaveApiSpecRequest,
SaveApiParamRequest,
ApiSpecInfo,
ApiParamInfo,
} 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 createService = (req: CreateServiceRequest) => post<ServiceInfo>('/services', 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 createServiceApi = (serviceId: number, req: CreateServiceApiRequest) =>
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 {
domain: string;
iconPath: string | null;
sortOrder: number;
apis: ServiceApiItem[];
}
export interface ApiDomainInfo {
domainId: number;
domainName: string;
iconPath: string | null;
sortOrder: number;
createdAt: string;
updatedAt: string;
}
export interface ServiceApiItem {
apiId: number;
serviceId: number;
@ -22,11 +33,20 @@ export interface ServiceCatalog {
serviceCode: string;
serviceName: string;
description: string | null;
serviceUrl: string | null;
healthStatus: 'UP' | 'DOWN' | 'UNKNOWN';
apiCount: number;
domains: DomainGroup[];
}
export interface PopularApi {
domain: string;
apiName: string;
apiId: number | null;
serviceId: number | null;
count: number;
}
export interface RecentApi {
apiId: number;
apiName: string;

파일 보기

@ -65,6 +65,7 @@ export interface ApiKeyRequestReviewDto {
adjustedApiIds?: number[];
adjustedFromDate?: string;
adjustedToDate?: string;
adjustedDailyRequestLimit?: number;
}
export interface Permission {

파일 보기

@ -97,3 +97,77 @@ export interface HealthHistory {
errorMessage: string | null;
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;
import com.gcsc.connection.apihub.dto.PopularApiResponse;
import com.gcsc.connection.apihub.dto.RecentApiResponse;
import com.gcsc.connection.apihub.dto.ServiceCatalogResponse;
import com.gcsc.connection.apihub.service.ApiHubService;
import com.gcsc.connection.common.dto.ApiResponse;
import com.gcsc.connection.service.dto.ApiDetailResponse;
import com.gcsc.connection.service.service.ServiceManagementService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* API Hub 카탈로그 최근 API 조회 컨트롤러
* API Hub 카탈로그 API 상세 조회 컨트롤러
*/
@RestController
@RequestMapping("/api/api-hub")
@ -21,6 +25,7 @@ import java.util.List;
public class ApiHubController {
private final ApiHubService apiHubService;
private final ServiceManagementService serviceManagementService;
/**
* 활성 서비스와 해당 서비스의 활성 API를 도메인별로 그룹화하여 카탈로그 형태로 반환
@ -39,4 +44,33 @@ public class ApiHubController {
List<RecentApiResponse> recentApis = apiHubService.getRecentApis();
return ResponseEntity.ok(ApiResponse.ok(recentApis));
}
/**
* 인기 API (최근 1주일 기준 호출 Top N)
*/
@GetMapping("/popular-apis")
public ResponseEntity<ApiResponse<List<PopularApiResponse>>> getPopularApis() {
List<PopularApiResponse> popularApis = apiHubService.getPopularApis(3);
return ResponseEntity.ok(ApiResponse.ok(popularApis));
}
/**
* 서비스 단건 카탈로그 조회
*/
@GetMapping("/services/{serviceId}")
public ResponseEntity<ApiResponse<ServiceCatalogResponse>> getServiceCatalog(
@PathVariable Long serviceId) {
ServiceCatalogResponse catalog = apiHubService.getServiceCatalog(serviceId);
return ResponseEntity.ok(ApiResponse.ok(catalog));
}
/**
* API 상세 명세 조회
*/
@GetMapping("/services/{serviceId}/apis/{apiId}")
public ResponseEntity<ApiResponse<ApiDetailResponse>> getApiDetail(
@PathVariable Long serviceId, @PathVariable Long apiId) {
ApiDetailResponse detail = serviceManagementService.getApiDetail(serviceId, apiId);
return ResponseEntity.ok(ApiResponse.ok(detail));
}
}

파일 보기

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

파일 보기

@ -0,0 +1,10 @@
package com.gcsc.connection.apihub.dto;
public record PopularApiResponse(
String domain,
String apiName,
Long apiId,
Long serviceId,
long count
) {
}

파일 보기

@ -1,6 +1,7 @@
package com.gcsc.connection.apihub.dto;
import com.gcsc.connection.service.dto.ServiceApiResponse;
import com.gcsc.connection.service.entity.SnpApiDomain;
import com.gcsc.connection.service.entity.SnpService;
import com.gcsc.connection.service.entity.SnpServiceApi;
@ -12,13 +13,27 @@ public record ServiceCatalogResponse(
Long serviceId,
String serviceCode,
String serviceName,
String serviceUrl,
String description,
String healthStatus,
int apiCount,
List<DomainGroup> domains
) {
/**
* 도메인 메타 정보(아이콘, 정렬) 없이 도메인명 기준 알파벳 정렬로 카탈로그 생성
*/
public static ServiceCatalogResponse from(SnpService service, List<SnpServiceApi> apis) {
return from(service, apis, Map.of());
}
/**
* 도메인 메타 정보(아이콘, 정렬) 포함하여 카탈로그 생성
*
* @param domainMap domainName SnpApiDomain 매핑
*/
public static ServiceCatalogResponse from(SnpService service, List<SnpServiceApi> apis,
Map<String, SnpApiDomain> domainMap) {
Map<String, List<ServiceApiResponse>> byDomain = apis.stream()
.collect(Collectors.groupingBy(
api -> api.getApiDomain() != null ? api.getApiDomain() : "",
@ -26,14 +41,29 @@ public record ServiceCatalogResponse(
));
List<DomainGroup> domainGroups = byDomain.entrySet().stream()
.sorted(Map.Entry.comparingByKey())
.map(entry -> new DomainGroup(entry.getKey(), entry.getValue()))
.sorted((a, b) -> {
SnpApiDomain da = domainMap.get(a.getKey());
SnpApiDomain db = domainMap.get(b.getKey());
int orderA = da != null ? da.getSortOrder() : Integer.MAX_VALUE;
int orderB = db != null ? db.getSortOrder() : Integer.MAX_VALUE;
if (orderA != orderB) {
return Integer.compare(orderA, orderB);
}
return a.getKey().compareTo(b.getKey());
})
.map(entry -> {
SnpApiDomain domainMeta = domainMap.get(entry.getKey());
String iconPath = domainMeta != null ? domainMeta.getIconPath() : null;
int sortOrder = domainMeta != null ? domainMeta.getSortOrder() : Integer.MAX_VALUE;
return new DomainGroup(entry.getKey(), iconPath, sortOrder, entry.getValue());
})
.toList();
return new ServiceCatalogResponse(
service.getServiceId(),
service.getServiceCode(),
service.getServiceName(),
service.getServiceUrl(),
service.getDescription(),
service.getHealthStatus().name(),
apis.size(),

파일 보기

@ -2,8 +2,12 @@ package com.gcsc.connection.apihub.service;
import com.gcsc.connection.apihub.dto.RecentApiResponse;
import com.gcsc.connection.apihub.dto.ServiceCatalogResponse;
import com.gcsc.connection.common.exception.BusinessException;
import com.gcsc.connection.common.exception.ErrorCode;
import com.gcsc.connection.service.entity.SnpApiDomain;
import com.gcsc.connection.service.entity.SnpService;
import com.gcsc.connection.service.entity.SnpServiceApi;
import com.gcsc.connection.service.repository.SnpApiDomainRepository;
import com.gcsc.connection.service.repository.SnpServiceApiRepository;
import com.gcsc.connection.service.repository.SnpServiceRepository;
import lombok.RequiredArgsConstructor;
@ -11,7 +15,13 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.gcsc.connection.monitoring.repository.SnpApiRequestLogRepository;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
@Service
@Slf4j
@ -20,23 +30,42 @@ public class ApiHubService {
private final SnpServiceRepository snpServiceRepository;
private final SnpServiceApiRepository snpServiceApiRepository;
private final SnpApiDomainRepository snpApiDomainRepository;
private final SnpApiRequestLogRepository snpApiRequestLogRepository;
/**
* 활성 서비스와 서비스의 활성 API를 도메인별로 그룹화하여 카탈로그 반환
*/
@Transactional(readOnly = true)
public List<ServiceCatalogResponse> getCatalog() {
Map<String, SnpApiDomain> domainMap = buildDomainMap();
List<SnpService> activeServices = snpServiceRepository.findByIsActiveTrue();
return activeServices.stream()
.map(service -> {
List<SnpServiceApi> activeApis = snpServiceApiRepository
.findByServiceServiceIdAndIsActiveTrue(service.getServiceId());
return ServiceCatalogResponse.from(service, activeApis);
return ServiceCatalogResponse.from(service, activeApis, domainMap);
})
.toList();
}
/**
* 서비스 단건 카탈로그 조회
*/
@Transactional(readOnly = true)
public ServiceCatalogResponse getServiceCatalog(Long serviceId) {
SnpService service = snpServiceRepository.findById(serviceId)
.filter(SnpService::getIsActive)
.orElseThrow(() -> new BusinessException(ErrorCode.SERVICE_NOT_FOUND));
List<SnpServiceApi> activeApis = snpServiceApiRepository
.findByServiceServiceIdAndIsActiveTrue(serviceId);
Map<String, SnpApiDomain> domainMap = buildDomainMap();
return ServiceCatalogResponse.from(service, activeApis, domainMap);
}
/**
* 최근 등록된 활성 API 상위 10건 반환
*/
@ -46,4 +75,26 @@ public class ApiHubService {
.map(RecentApiResponse::from)
.toList();
}
/**
* 최근 1주일 기준 인기 API 상위 N건
*/
@Transactional(readOnly = true)
public List<com.gcsc.connection.apihub.dto.PopularApiResponse> getPopularApis(int limit) {
LocalDateTime since = LocalDateTime.now().minusDays(7);
return snpApiRequestLogRepository.findTopApisForHub(since, limit).stream()
.map(row -> new com.gcsc.connection.apihub.dto.PopularApiResponse(
(String) row[0],
(String) row[1],
row[2] != null ? ((Number) row[2]).longValue() : null,
row[3] != null ? ((Number) row[3]).longValue() : null,
((Number) row[4]).longValue()
))
.toList();
}
private Map<String, SnpApiDomain> buildDomainMap() {
return snpApiDomainRepository.findAllByOrderBySortOrderAscDomainNameAsc().stream()
.collect(Collectors.toMap(SnpApiDomain::getDomainName, Function.identity()));
}
}

파일 보기

@ -9,6 +9,7 @@ public record ApiKeyRequestReviewDto(
String reviewComment,
List<Long> adjustedApiIds,
String adjustedFromDate,
String adjustedToDate
String adjustedToDate,
Long adjustedDailyRequestLimit
) {
}

파일 보기

@ -3,6 +3,7 @@ package com.gcsc.connection.apikey.dto;
import jakarta.validation.constraints.NotBlank;
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")
private LocalDateTime lastUsedAt;
@Column(name = "daily_request_limit")
private Long dailyRequestLimit;
@Builder
public SnpApiKey(SnpUser user, String apiKey, String apiKeyPrefix, String keyName,
ApiKeyStatus status, SnpUser approvedBy, LocalDateTime approvedAt,
LocalDateTime expiresAt) {
LocalDateTime expiresAt, Long dailyRequestLimit) {
this.user = user;
this.apiKey = apiKey;
this.apiKeyPrefix = apiKeyPrefix;
@ -72,6 +75,7 @@ public class SnpApiKey extends BaseEntity {
this.approvedBy = approvedBy;
this.approvedAt = approvedAt;
this.expiresAt = expiresAt;
this.dailyRequestLimit = dailyRequestLimit;
}
public void revoke() {

파일 보기

@ -150,6 +150,11 @@ public class ApiKeyRequestService {
String prefix = rawKey.substring(0, PREFIX_LENGTH);
String encryptedKey = aesEncryptor.encrypt(rawKey);
// 일일 요청 제한: 검토자가 조정한 > 신청자 입력값 > null(무제한)
Long dailyLimit = dto.adjustedDailyRequestLimit() != null
? dto.adjustedDailyRequestLimit()
: request.getDailyRequestEstimate();
SnpApiKey apiKey = SnpApiKey.builder()
.user(request.getUser())
.apiKey(encryptedKey)
@ -159,6 +164,7 @@ public class ApiKeyRequestService {
.approvedBy(reviewer)
.approvedAt(LocalDateTime.now())
.expiresAt(request.getUsageToDate())
.dailyRequestLimit(dailyLimit)
.build();
SnpApiKey savedKey = snpApiKeyRepository.save(apiKey);

파일 보기

@ -75,6 +75,7 @@ public class ApiKeyService {
.apiKeyPrefix(prefix)
.keyName(request.keyName())
.status(ApiKeyStatus.ACTIVE)
.dailyRequestLimit(request.dailyRequestLimit())
.build();
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> {
private final boolean success;
private final String code;
private final String message;
private final T data;
@ -34,4 +35,12 @@ public class ApiResponse<T> {
.message(message)
.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_PERMISSION_DENIED(403, "GW005", "해당 API에 대한 권한이 없습니다"),
GATEWAY_PROXY_FAILED(502, "GW006", "서비스 요청에 실패했습니다"),
GATEWAY_DAILY_LIMIT_EXCEEDED(429, "GW007", "일일 최대 호출 건수 제한으로 사용할 수 없습니다"),
INTERNAL_ERROR(500, "SYS001", "시스템 오류가 발생했습니다");
private final int status;

파일 보기

@ -26,7 +26,7 @@ public class GlobalExceptionHandler {
log.warn("Business exception: {} - {}", errorCode.getCode(), errorCode.getMessage());
return ResponseEntity
.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("요청 본문을 읽을 수 없습니다"));
}
/**
* 정적 리소스 미발견 (이미지 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);
return gatewayService.proxyRequest(serviceCode, remainingPath, request);
} 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 에러 응답 생성
*/
private ResponseEntity<byte[]> buildErrorResponse(int status, String message) {
String json = "{\"success\":false,\"message\":\"" + escapeJson(message) + "\"}";
private ResponseEntity<byte[]> buildErrorResponse(int status, String code, String message) {
String json = "{\"success\":false,\"code\":\"" + escapeJson(code) + "\",\"message\":\"" + escapeJson(message) + "\"}";
return ResponseEntity.status(status)
.contentType(MediaType.APPLICATION_JSON)
.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.util.AesEncryptor;
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.service.entity.SnpService;
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.WebClientResponseException;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.Enumeration;
import java.util.List;
@ -34,8 +36,9 @@ import java.util.Set;
public class GatewayService {
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(
"host", "x-api-key", "connection", "content-length"
"host", "connection", "content-length"
);
private final SnpApiKeyRepository snpApiKeyRepository;
@ -45,6 +48,7 @@ public class GatewayService {
private final AesEncryptor aesEncryptor;
private final WebClient webClient;
private final RequestLogService requestLogService;
private final SnpApiRequestLogRepository snpApiRequestLogRepository;
/**
* API Gateway 프록시 요청 처리
@ -70,8 +74,8 @@ public class GatewayService {
// 2. 대상 URL 조합 (실패 로그에도 사용)
targetUrl = buildTargetUrl(service.getServiceUrl(), remainingPath, request);
// 3. API Key 추출
String rawKey = request.getHeader("X-API-KEY");
// 3. API Key 추출 (쿼리 파라미터 authKey)
String rawKey = request.getParameter(AUTH_KEY_PARAM);
if (rawKey == null || rawKey.isBlank()) {
throw new BusinessException(ErrorCode.GATEWAY_API_KEY_MISSING);
}
@ -82,7 +86,10 @@ public class GatewayService {
// 5. Key 상태/만료 검증
validateApiKey(apiKey);
// 6. ServiceApi 조회 (경로 + 메서드 매칭, {변수} 패턴 지원)
// 6. 일일 요청량 제한 검증
validateDailyLimit(apiKey);
// 7. ServiceApi 조회 (경로 + 메서드 매칭, {변수} 패턴 지원)
String apiPath = remainingPath.startsWith("/") ? remainingPath : "/" + remainingPath;
SnpServiceApi serviceApi = matchServiceApi(service.getServiceId(), apiPath, request.getMethod());
@ -109,7 +116,8 @@ public class GatewayService {
} catch (BusinessException e) {
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().getMessage(), requestedAt);
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 구성
*/
@ -206,7 +244,13 @@ public class GatewayService {
String queryString = request.getQueryString();
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();
@ -327,9 +371,7 @@ public class GatewayService {
Enumeration<String> headerNames = request.getHeaderNames();
while (headerNames.hasMoreElements()) {
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();
}

파일 보기

@ -20,7 +20,8 @@ public class WebViewController {
@GetMapping({"/dashboard", "/dashboard/**",
"/monitoring/**", "/statistics/**",
"/apikeys", "/apikeys/**",
"/admin/**"})
"/admin/**",
"/api-hub", "/api-hub/**"})
public String forward() {
return "forward:/index.html";
}

파일 보기

@ -12,6 +12,23 @@ import java.util.List;
public interface SnpApiRequestLogRepository extends JpaRepository<SnpApiRequestLog, Long>,
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, " +
"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;
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.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.ServiceResponse;
import com.gcsc.connection.service.dto.UpdateServiceApiRequest;
import com.gcsc.connection.service.dto.UpdateServiceRequest;
import com.gcsc.connection.service.service.ServiceManagementService;
import jakarta.validation.Valid;
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;
@ -39,6 +46,15 @@ public class ServiceController {
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));
}
/**
* 서비스 삭제 (비활성화)
*/
@DeleteMapping("/{id}")
public ResponseEntity<ApiResponse<Void>> deleteService(@PathVariable Long id) {
serviceManagementService.deleteService(id);
return ResponseEntity.ok(ApiResponse.ok(null));
}
/**
* 서비스 API 목록 조회
*/
@ -80,4 +105,57 @@ public class ServiceController {
ServiceApiResponse api = serviceManagementService.createServiceApi(id, request);
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.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.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.ServiceResponse;
import com.gcsc.connection.service.dto.UpdateServiceApiRequest;
import com.gcsc.connection.service.dto.UpdateServiceRequest;
import com.gcsc.connection.service.entity.SnpService;
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.SnpServiceApiSpecRepository;
import com.gcsc.connection.service.repository.SnpServiceRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@ -25,6 +35,8 @@ public class ServiceManagementService {
private final SnpServiceRepository snpServiceRepository;
private final SnpServiceApiRepository snpServiceApiRepository;
private final SnpServiceApiSpecRepository snpServiceApiSpecRepository;
private final SnpServiceApiParamRepository snpServiceApiParamRepository;
/**
* 전체 서비스 목록 조회
@ -89,6 +101,163 @@ public class ServiceManagementService {
.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 생성
*/