diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 5927ecc..9d8d1e7 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -6,6 +6,29 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/ko/1.0.0/). ## [Unreleased] +## [2026-04-14] + +### 추가 + +- S&P API HUB SPA 대시보드 (배너, 인기 API, 최신 API, 서비스 카드) (#40) +- API Hub 서비스 트리 사이드바 레이아웃 (서비스 > 도메인 > API) (#40) +- 서비스별 API 목록 페이지 (도메인별 그룹) (#40) +- API 상세 명세 페이지 (#40) +- 백엔드 카탈로그/최신 API 조회 엔드포인트 (#40) +- API 관리 상세 화면 (Spec/Param CRUD, 출력결과 JSON 파싱) (#42) +- 시스템 공통 설정 관리 (SnpSystemConfig, 공통 샘플 코드) (#42) +- API HUB 상세 화면 개선 (아코디언, 샘플 URL, 출력결과 2열) (#42) +- Gateway API 인증: X-API-KEY 헤더 → authKey 쿼리 파라미터 변경 (#42) +- 일일 요청량 제한 기능 (daily_request_limit, HTTP 429) (#42) +- 에러 응답에 code 필드 추가, 인증/권한 거부 로그 DENIED 분리 (#42) +- API Key 검토 모달 예상 요청량 수정 기능 (#42) +- 도메인 관리 (SnpApiDomain CRUD, SVG 아이콘, 정렬순서) (#42) +- API HUB 사이드바: 서비스 기반 → 도메인 기반 플랫 메뉴 변경 (#42) +- 도메인 상세 페이지 (API 리스트 뷰, 검색) (#42) +- API 사용 신청 모달 (API HUB 상세 화면 내 도메인 기반 체크박스 선택) (#42) +- API 선택 UI: 서비스 기반 → 도메인 기반 변경 (Path/Method 제거) (#42) +- 대시보드 개선: 도메인 이미지 카드, 인기 API 주간 Top 3, 랭킹 뱃지 (#42) + ## [2026-04-13] ### 추가 diff --git a/frontend/public/images/domains/ais.jpg b/frontend/public/images/domains/ais.jpg new file mode 100644 index 0000000..d0c98d4 Binary files /dev/null and b/frontend/public/images/domains/ais.jpg differ diff --git a/frontend/public/images/domains/company.jpg b/frontend/public/images/domains/company.jpg new file mode 100644 index 0000000..7a4b7d1 Binary files /dev/null and b/frontend/public/images/domains/company.jpg differ diff --git a/frontend/public/images/domains/compliance.jpg b/frontend/public/images/domains/compliance.jpg new file mode 100644 index 0000000..4233e40 Binary files /dev/null and b/frontend/public/images/domains/compliance.jpg differ diff --git a/frontend/public/images/domains/risk.jpg b/frontend/public/images/domains/risk.jpg new file mode 100644 index 0000000..d472c71 Binary files /dev/null and b/frontend/public/images/domains/risk.jpg differ diff --git a/frontend/public/images/domains/ship.jpg b/frontend/public/images/domains/ship.jpg new file mode 100644 index 0000000..0155ad5 Binary files /dev/null and b/frontend/public/images/domains/ship.jpg differ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c9d77ac..ca2a11c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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'; @@ -18,6 +22,11 @@ import UserStatsPage from './pages/statistics/UserStatsPage'; import ApiStatsPage from './pages/statistics/ApiStatsPage'; import TenantStatsPage from './pages/statistics/TenantStatsPage'; import UsageTrendPage from './pages/statistics/UsageTrendPage'; +import ApiHubLayout from './layouts/ApiHubLayout'; +import ApiHubDashboardPage from './pages/apihub/ApiHubDashboardPage'; +import ApiHubServicePage from './pages/apihub/ApiHubServicePage'; +import ApiHubApiDetailPage from './pages/apihub/ApiHubApiDetailPage'; +import ApiHubDomainPage from './pages/apihub/ApiHubDomainPage'; import NotFoundPage from './pages/NotFoundPage'; import RoleGuard from './components/RoleGuard'; @@ -45,10 +54,20 @@ const App = () => { } /> } /> } /> + } /> + } /> + } /> + } /> } /> } /> } /> + }> + } /> + } /> + } /> + } /> + diff --git a/frontend/src/layouts/ApiHubLayout.tsx b/frontend/src/layouts/ApiHubLayout.tsx new file mode 100644 index 0000000..385d280 --- /dev/null +++ b/frontend/src/layouts/ApiHubLayout.tsx @@ -0,0 +1,335 @@ +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'; +import { getCatalog } from '../services/apiHubService'; +import type { ServiceCatalog } from '../types/apihub'; + +const ROLES = ['ADMIN', 'MANAGER', 'USER'] as const; + +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; + // 형태에서 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]; +}; + +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(); + const { theme, toggleTheme } = useTheme(); + const navigate = useNavigate(); + const location = useLocation(); + + const [catalog, setCatalog] = useState([]); + const [loading, setLoading] = useState(true); + const [openDomains, setOpenDomains] = useState>({}); + const [searchQuery, setSearchQuery] = useState(''); + + useEffect(() => { + getCatalog() + .then((res) => { + const items = res.data ?? []; + setCatalog(items); + }) + .finally(() => setLoading(false)); + }, []); + + const toggleDomain = (key: string) => { + setOpenDomains((prev) => ({ ...prev, [key]: !prev[key] })); + }; + + // 서비스 계층을 제거하고 도메인 기준으로 플랫하게 재그룹핑 + const domainGroups = useMemo(() => { + const map = new Map(); + 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 => dg !== null); + }, [domainGroups, searchQuery]); + + const isSearching = searchQuery.trim().length > 0; + + return ( +
+ {/* Sidebar */} + + + {/* Main content */} +
+ {/* Header */} +
+
+
+ + Role: +
+ {ROLES.map((role) => ( + + ))} +
+
+
+ + {/* Content */} +
+ +
+
+
+ ); +}; + +export default ApiHubLayout; diff --git a/frontend/src/layouts/MainLayout.tsx b/frontend/src/layouts/MainLayout.tsx index 64d1a23..8d0422f 100644 --- a/frontend/src/layouts/MainLayout.tsx +++ b/frontend/src/layouts/MainLayout.tsx @@ -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' }, ], @@ -91,6 +94,21 @@ const MainLayout = () => { Dashboard + {/* API Hub */} + + `flex items-center gap-3 rounded-lg px-3 py-2 mt-4 text-sm font-medium transition-colors ${ + isActive ? 'bg-gray-700 text-white' : 'text-gray-300 hover:bg-gray-800 hover:text-white' + }` + } + > + + + + API Hub + + {/* Nav Groups */} {navGroups.map((group) => { if (group.adminOnly && user?.role !== 'ADMIN') return null; diff --git a/frontend/src/pages/admin/ApiEditPage.tsx b/frontend/src/pages/admin/ApiEditPage.tsx new file mode 100644 index 0000000..abffb11 --- /dev/null +++ b/frontend/src/pages/admin/ApiEditPage.tsx @@ -0,0 +1,822 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useParams, useNavigate, Link } from 'react-router-dom'; +import type { + ServiceInfo, + ApiDetailInfo, + UpdateServiceApiRequest, + SaveApiSpecRequest, + SaveApiParamRequest, +} from '../../types/service'; +import { + getServices, + getApiDetail, + updateServiceApi, + saveApiSpec, + saveApiParams, + deleteServiceApi, + getDomains, +} from '../../services/serviceService'; +import type { ApiDomainInfo } from '../../types/apihub'; + +const METHOD_COLOR: Record = { + GET: 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300', + POST: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300', + PUT: 'bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300', + DELETE: 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300', +}; + +const INPUT_CLS = + 'w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none text-sm'; + +const TABLE_INPUT_CLS = + 'w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded px-2 py-1.5 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none'; + +const LABEL_CLS = 'block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1'; + +const ApiEditPage = () => { + const { serviceId: serviceIdStr, apiId: apiIdStr } = useParams<{ + serviceId: string; + apiId: string; + }>(); + const navigate = useNavigate(); + + const serviceId = Number(serviceIdStr); + const apiId = Number(apiIdStr); + + // Page state + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [notFound, setNotFound] = useState(false); + const [saving, setSaving] = useState(false); + const [saveMessage, setSaveMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); + + // Meta + const [serviceName, setServiceName] = useState(''); + + // Domains + const [domains, setDomains] = useState([]); + + // Basic info + const [apiMethod, setApiMethod] = useState('GET'); + const [apiPath, setApiPath] = useState(''); + const [apiName, setApiName] = useState(''); + const [apiDomain, setApiDomain] = useState(''); + const [apiSection, setApiSection] = useState(''); + const [apiDescription, setApiDescription] = useState(''); + const [apiIsActive, setApiIsActive] = useState(true); + + // Spec + const [sampleUrl, setSampleUrl] = useState(''); + const [authRequired, setAuthRequired] = useState(false); + const [authType, setAuthType] = useState(''); + const [deprecated, setDeprecated] = useState(false); + const [dataFormat, setDataFormat] = useState(''); + const [referenceUrl, setReferenceUrl] = useState(''); + const [specNote, setSpecNote] = useState(''); + + // Params + const [requestParams, setRequestParams] = useState([]); + const [responseParams, setResponseParams] = useState([]); + + const populateForm = useCallback((data: ApiDetailInfo, services: ServiceInfo[]) => { + const { api, spec, requestParams: rp, responseParams: resp } = data; + + const svc = services.find((s) => s.serviceId === api.serviceId); + if (svc) setServiceName(svc.serviceName); + + setApiMethod(api.apiMethod); + setApiPath(api.apiPath); + setApiName(api.apiName); + setApiDomain(api.apiDomain || ''); + setApiSection(api.apiSection || ''); + setApiDescription(api.description || ''); + setApiIsActive(api.isActive); + + if (spec) { + setSampleUrl(spec.sampleUrl || ''); + setAuthRequired(spec.authRequired); + setAuthType(spec.authType || ''); + setDeprecated(spec.deprecated); + setDataFormat(spec.dataFormat || ''); + setReferenceUrl(spec.referenceUrl || ''); + setSpecNote(spec.note || ''); + } + + setRequestParams( + rp.map((p, i) => ({ + paramType: 'REQUEST' as const, + paramName: p.paramName, + paramMeaning: p.paramMeaning || undefined, + paramDescription: p.paramDescription || undefined, + required: p.required, + defaultValue: p.defaultValue || undefined, + inputType: p.inputType || 'TEXT', + sortOrder: i, + })), + ); + + setResponseParams( + resp.map((p, i) => ({ + paramType: 'RESPONSE' as const, + paramName: p.paramName, + paramMeaning: p.paramMeaning || undefined, + sortOrder: i, + })), + ); + }, []); + + useEffect(() => { + const fetchData = async () => { + try { + setLoading(true); + setError(null); + + const [svcRes, detailRes, domainRes] = await Promise.all([ + getServices(), + getApiDetail(serviceId, apiId), + getDomains(), + ]); + + if (domainRes.success && domainRes.data) { + setDomains(domainRes.data); + } + + if (!detailRes.success || !detailRes.data) { + if (detailRes.message?.includes('404') || detailRes.message?.includes('찾을 수 없')) { + setNotFound(true); + } else { + setError(detailRes.message || 'API 정보를 불러오는데 실패했습니다.'); + } + return; + } + + const services = svcRes.success && svcRes.data ? svcRes.data : []; + populateForm(detailRes.data, services); + } catch { + setError('API 정보를 불러오는데 실패했습니다.'); + } finally { + setLoading(false); + } + }; + + fetchData(); + }, [serviceId, apiId, populateForm]); + + // Param helpers + const addParam = () => { + const newParam: SaveApiParamRequest = { + paramType: 'REQUEST', + paramName: '', + inputType: 'TEXT', + sortOrder: requestParams.length, + }; + setRequestParams((prev) => [...prev, newParam]); + }; + + const updateParam = (index: number, field: string, value: string | boolean | undefined) => { + setRequestParams((prev) => prev.map((p, i) => (i === index ? { ...p, [field]: value } : p))); + }; + + const removeParam = (index: number) => { + setRequestParams((prev) => prev.filter((_, i) => i !== index)); + }; + + const addResponseParam = () => { + const newParam: SaveApiParamRequest = { + paramType: 'RESPONSE', + paramName: '', + sortOrder: responseParams.length, + }; + setResponseParams((prev) => [...prev, newParam]); + }; + + const updateResponseParam = (index: number, field: string, value: string | undefined) => { + setResponseParams((prev) => prev.map((p, i) => (i === index ? { ...p, [field]: value } : p))); + }; + + const removeResponseParam = (index: number) => { + setResponseParams((prev) => prev.filter((_, i) => i !== index)); + }; + + const [jsonInput, setJsonInput] = useState(''); + const [jsonError, setJsonError] = useState(null); + const [showJsonInput, setShowJsonInput] = useState(false); + + const parseJsonToParams = () => { + setJsonError(null); + try { + const parsed = JSON.parse(jsonInput); + const params: SaveApiParamRequest[] = []; + + const extract = (obj: unknown, prefix: string) => { + if (obj === null || obj === undefined) return; + if (Array.isArray(obj)) { + params.push({ paramType: 'RESPONSE', paramName: prefix + '[]', sortOrder: params.length }); + if (obj.length > 0 && typeof obj[0] === 'object' && obj[0] !== null) { + extract(obj[0], prefix + '[].'); + } + } else if (typeof obj === 'object') { + for (const key of Object.keys(obj)) { + const fullKey = prefix ? prefix + key : key; + const val = (obj as Record)[key]; + if (val !== null && typeof val === 'object') { + extract(val, fullKey + (Array.isArray(val) ? '' : '.')); + } else { + params.push({ paramType: 'RESPONSE', paramName: fullKey, sortOrder: params.length }); + } + } + } + }; + + extract(parsed, ''); + if (params.length === 0) { + setJsonError('파싱할 키가 없습니다.'); + return; + } + setResponseParams(params); + setJsonInput(''); + setShowJsonInput(false); + } catch { + setJsonError('올바른 JSON 형식이 아닙니다.'); + } + }; + + const handleSave = async () => { + setSaving(true); + setSaveMessage(null); + + try { + const basicReq: UpdateServiceApiRequest = { + apiMethod, + apiPath, + apiName, + apiDomain: apiDomain || undefined, + apiSection: apiSection || undefined, + description: apiDescription || undefined, + isActive: apiIsActive, + }; + + const specReq: SaveApiSpecRequest = { + sampleUrl: sampleUrl || undefined, + authRequired, + authType: authType || undefined, + deprecated, + dataFormat: dataFormat || undefined, + referenceUrl: referenceUrl || undefined, + note: specNote || undefined, + }; + + const allParams: SaveApiParamRequest[] = [ + ...requestParams.map((p, i) => ({ ...p, sortOrder: i })), + ...responseParams.map((p, i) => ({ ...p, sortOrder: i })), + ]; + + const [basicRes, specRes, paramsRes] = await Promise.all([ + updateServiceApi(serviceId, apiId, basicReq), + saveApiSpec(serviceId, apiId, specReq), + saveApiParams(serviceId, apiId, allParams), + ]); + + const basicOk = basicRes.success; + const specOk = specRes.success; + const paramsOk = paramsRes.success; + + if (basicOk && specOk && paramsOk) { + setSaveMessage({ type: 'success', text: '저장되었습니다.' }); + } else { + const errMsg = + (!basicOk ? basicRes.message : null) || + (!specOk ? specRes.message : null) || + (!paramsOk ? paramsRes.message : null) || + '일부 항목 저장에 실패했습니다.'; + setSaveMessage({ type: 'error', text: errMsg }); + } + } catch { + setSaveMessage({ type: 'error', text: '저장 중 오류가 발생했습니다.' }); + } finally { + setSaving(false); + setTimeout(() => setSaveMessage(null), 3000); + } + }; + + const handleDelete = async () => { + if (!window.confirm('이 API를 삭제하시겠습니까?')) return; + + try { + const res = await deleteServiceApi(serviceId, apiId); + if (res.success) { + navigate('/admin/apis'); + } else { + setSaveMessage({ type: 'error', text: res.message || 'API 삭제에 실패했습니다.' }); + } + } catch { + setSaveMessage({ type: 'error', text: 'API 삭제 중 오류가 발생했습니다.' }); + } + }; + + // Loading + if (loading) { + return ( +
+
+
+ ); + } + + // Not found + if (notFound) { + return ( +
+

API를 찾을 수 없습니다.

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

{apiName}

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

기본 정보

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