generated from gc/template-java-maven
- API 명세(Spec) 및 파라미터(Param) CRUD 엔드포인트 추가 - API 관리 상세 편집 페이지(ApiEditPage) 구현 - API 목록 관리 페이지(ApisPage) 구현 - 요청인자/출력결과 편집 + JSON 파싱 기능 - 프론트엔드 타입/서비스 정의 추가 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
431 lines
18 KiB
TypeScript
431 lines
18 KiB
TypeScript
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;
|