generated from gc/template-java-maven
Compare commits
8 커밋
bf8a2e5fc4
...
b37867b8ad
| 작성자 | SHA1 | 날짜 | |
|---|---|---|---|
| b37867b8ad | |||
| 01fe6e62f7 | |||
| 17d870c06a | |||
| dfee04f703 | |||
| 5ce1ca233d | |||
| dd1ac022d2 | |||
| ac0f51b816 | |||
| a9cdf96481 |
BIN
frontend/public/images/domains/ais.jpg
Normal file
BIN
frontend/public/images/domains/ais.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | 크기: 658 KiB |
BIN
frontend/public/images/domains/company.jpg
Normal file
BIN
frontend/public/images/domains/company.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | 크기: 1.8 MiB |
BIN
frontend/public/images/domains/compliance.jpg
Normal file
BIN
frontend/public/images/domains/compliance.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | 크기: 4.0 MiB |
BIN
frontend/public/images/domains/risk.jpg
Normal file
BIN
frontend/public/images/domains/risk.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | 크기: 2.7 MiB |
BIN
frontend/public/images/domains/ship.jpg
Normal file
BIN
frontend/public/images/domains/ship.jpg
Normal file
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&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' },
|
||||
],
|
||||
|
||||
822
frontend/src/pages/admin/ApiEditPage.tsx
Normal file
822
frontend/src/pages/admin/ApiEditPage.tsx
Normal file
@ -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;
|
||||
430
frontend/src/pages/admin/ApisPage.tsx
Normal file
430
frontend/src/pages/admin/ApisPage.tsx
Normal file
@ -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;
|
||||
302
frontend/src/pages/admin/DomainsPage.tsx
Normal file
302
frontend/src/pages/admin/DomainsPage.tsx
Normal file
@ -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;
|
||||
107
frontend/src/pages/admin/SampleCodePage.tsx
Normal file
107
frontend/src/pages/admin/SampleCodePage.tsx
Normal file
@ -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 상세 페이지의 '요청 URL 생성' 섹션 하단에 표시됩니다.
|
||||
</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&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&P API HUB</h1>
|
||||
<p className="text-indigo-200">S&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>
|
||||
);
|
||||
};
|
||||
|
||||
238
frontend/src/pages/apihub/ApiHubDomainPage.tsx
Normal file
238
frontend/src/pages/apihub/ApiHubDomainPage.tsx
Normal file
@ -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}`);
|
||||
|
||||
12
frontend/src/services/configService.ts
Normal file
12
frontend/src/services/configService.ts
Normal file
@ -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
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 생성
|
||||
*/
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user