feat(api): API 관리 상세 화면 구현

- API 명세(Spec) 및 파라미터(Param) CRUD 엔드포인트 추가
- API 관리 상세 편집 페이지(ApiEditPage) 구현
- API 목록 관리 페이지(ApisPage) 구현
- 요청인자/출력결과 편집 + JSON 파싱 기능
- 프론트엔드 타입/서비스 정의 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
HYOJIN 2026-04-14 13:57:09 +09:00
부모 bf8a2e5fc4
커밋 a9cdf96481
19개의 변경된 파일2016개의 추가작업 그리고 158개의 파일을 삭제

파일 보기

@ -0,0 +1,822 @@
import { useState, useEffect, useCallback } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import type {
ServiceInfo,
ApiDetailInfo,
UpdateServiceApiRequest,
SaveApiSpecRequest,
SaveApiParamRequest,
} from '../../types/service';
import {
getServices,
getApiDetail,
updateServiceApi,
saveApiSpec,
saveApiParams,
deleteServiceApi,
getDomains,
} from '../../services/serviceService';
import type { ApiDomainInfo } from '../../types/apihub';
const METHOD_COLOR: Record<string, string> = {
GET: 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300',
POST: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300',
PUT: 'bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300',
DELETE: 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300',
};
const INPUT_CLS =
'w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none text-sm';
const TABLE_INPUT_CLS =
'w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded px-2 py-1.5 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none';
const LABEL_CLS = 'block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1';
const ApiEditPage = () => {
const { serviceId: serviceIdStr, apiId: apiIdStr } = useParams<{
serviceId: string;
apiId: string;
}>();
const navigate = useNavigate();
const serviceId = Number(serviceIdStr);
const apiId = Number(apiIdStr);
// Page state
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [notFound, setNotFound] = useState(false);
const [saving, setSaving] = useState(false);
const [saveMessage, setSaveMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
// Meta
const [serviceName, setServiceName] = useState('');
// Domains
const [domains, setDomains] = useState<ApiDomainInfo[]>([]);
// Basic info
const [apiMethod, setApiMethod] = useState('GET');
const [apiPath, setApiPath] = useState('');
const [apiName, setApiName] = useState('');
const [apiDomain, setApiDomain] = useState('');
const [apiSection, setApiSection] = useState('');
const [apiDescription, setApiDescription] = useState('');
const [apiIsActive, setApiIsActive] = useState(true);
// Spec
const [sampleUrl, setSampleUrl] = useState('');
const [authRequired, setAuthRequired] = useState(false);
const [authType, setAuthType] = useState('');
const [deprecated, setDeprecated] = useState(false);
const [dataFormat, setDataFormat] = useState('');
const [referenceUrl, setReferenceUrl] = useState('');
const [specNote, setSpecNote] = useState('');
// Params
const [requestParams, setRequestParams] = useState<SaveApiParamRequest[]>([]);
const [responseParams, setResponseParams] = useState<SaveApiParamRequest[]>([]);
const populateForm = useCallback((data: ApiDetailInfo, services: ServiceInfo[]) => {
const { api, spec, requestParams: rp, responseParams: resp } = data;
const svc = services.find((s) => s.serviceId === api.serviceId);
if (svc) setServiceName(svc.serviceName);
setApiMethod(api.apiMethod);
setApiPath(api.apiPath);
setApiName(api.apiName);
setApiDomain(api.apiDomain || '');
setApiSection(api.apiSection || '');
setApiDescription(api.description || '');
setApiIsActive(api.isActive);
if (spec) {
setSampleUrl(spec.sampleUrl || '');
setAuthRequired(spec.authRequired);
setAuthType(spec.authType || '');
setDeprecated(spec.deprecated);
setDataFormat(spec.dataFormat || '');
setReferenceUrl(spec.referenceUrl || '');
setSpecNote(spec.note || '');
}
setRequestParams(
rp.map((p, i) => ({
paramType: 'REQUEST' as const,
paramName: p.paramName,
paramMeaning: p.paramMeaning || undefined,
paramDescription: p.paramDescription || undefined,
required: p.required,
defaultValue: p.defaultValue || undefined,
inputType: p.inputType || 'TEXT',
sortOrder: i,
})),
);
setResponseParams(
resp.map((p, i) => ({
paramType: 'RESPONSE' as const,
paramName: p.paramName,
paramMeaning: p.paramMeaning || undefined,
sortOrder: i,
})),
);
}, []);
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
setError(null);
const [svcRes, detailRes, domainRes] = await Promise.all([
getServices(),
getApiDetail(serviceId, apiId),
getDomains(),
]);
if (domainRes.success && domainRes.data) {
setDomains(domainRes.data);
}
if (!detailRes.success || !detailRes.data) {
if (detailRes.message?.includes('404') || detailRes.message?.includes('찾을 수 없')) {
setNotFound(true);
} else {
setError(detailRes.message || 'API 정보를 불러오는데 실패했습니다.');
}
return;
}
const services = svcRes.success && svcRes.data ? svcRes.data : [];
populateForm(detailRes.data, services);
} catch {
setError('API 정보를 불러오는데 실패했습니다.');
} finally {
setLoading(false);
}
};
fetchData();
}, [serviceId, apiId, populateForm]);
// Param helpers
const addParam = () => {
const newParam: SaveApiParamRequest = {
paramType: 'REQUEST',
paramName: '',
inputType: 'TEXT',
sortOrder: requestParams.length,
};
setRequestParams((prev) => [...prev, newParam]);
};
const updateParam = (index: number, field: string, value: string | boolean | undefined) => {
setRequestParams((prev) => prev.map((p, i) => (i === index ? { ...p, [field]: value } : p)));
};
const removeParam = (index: number) => {
setRequestParams((prev) => prev.filter((_, i) => i !== index));
};
const addResponseParam = () => {
const newParam: SaveApiParamRequest = {
paramType: 'RESPONSE',
paramName: '',
sortOrder: responseParams.length,
};
setResponseParams((prev) => [...prev, newParam]);
};
const updateResponseParam = (index: number, field: string, value: string | undefined) => {
setResponseParams((prev) => prev.map((p, i) => (i === index ? { ...p, [field]: value } : p)));
};
const removeResponseParam = (index: number) => {
setResponseParams((prev) => prev.filter((_, i) => i !== index));
};
const [jsonInput, setJsonInput] = useState('');
const [jsonError, setJsonError] = useState<string | null>(null);
const [showJsonInput, setShowJsonInput] = useState(false);
const parseJsonToParams = () => {
setJsonError(null);
try {
const parsed = JSON.parse(jsonInput);
const params: SaveApiParamRequest[] = [];
const extract = (obj: unknown, prefix: string) => {
if (obj === null || obj === undefined) return;
if (Array.isArray(obj)) {
params.push({ paramType: 'RESPONSE', paramName: prefix + '[]', sortOrder: params.length });
if (obj.length > 0 && typeof obj[0] === 'object' && obj[0] !== null) {
extract(obj[0], prefix + '[].');
}
} else if (typeof obj === 'object') {
for (const key of Object.keys(obj)) {
const fullKey = prefix ? prefix + key : key;
const val = (obj as Record<string, unknown>)[key];
if (val !== null && typeof val === 'object') {
extract(val, fullKey + (Array.isArray(val) ? '' : '.'));
} else {
params.push({ paramType: 'RESPONSE', paramName: fullKey, sortOrder: params.length });
}
}
}
};
extract(parsed, '');
if (params.length === 0) {
setJsonError('파싱할 키가 없습니다.');
return;
}
setResponseParams(params);
setJsonInput('');
setShowJsonInput(false);
} catch {
setJsonError('올바른 JSON 형식이 아닙니다.');
}
};
const handleSave = async () => {
setSaving(true);
setSaveMessage(null);
try {
const basicReq: UpdateServiceApiRequest = {
apiMethod,
apiPath,
apiName,
apiDomain: apiDomain || undefined,
apiSection: apiSection || undefined,
description: apiDescription || undefined,
isActive: apiIsActive,
};
const specReq: SaveApiSpecRequest = {
sampleUrl: sampleUrl || undefined,
authRequired,
authType: authType || undefined,
deprecated,
dataFormat: dataFormat || undefined,
referenceUrl: referenceUrl || undefined,
note: specNote || undefined,
};
const allParams: SaveApiParamRequest[] = [
...requestParams.map((p, i) => ({ ...p, sortOrder: i })),
...responseParams.map((p, i) => ({ ...p, sortOrder: i })),
];
const [basicRes, specRes, paramsRes] = await Promise.all([
updateServiceApi(serviceId, apiId, basicReq),
saveApiSpec(serviceId, apiId, specReq),
saveApiParams(serviceId, apiId, allParams),
]);
const basicOk = basicRes.success;
const specOk = specRes.success;
const paramsOk = paramsRes.success;
if (basicOk && specOk && paramsOk) {
setSaveMessage({ type: 'success', text: '저장되었습니다.' });
} else {
const errMsg =
(!basicOk ? basicRes.message : null) ||
(!specOk ? specRes.message : null) ||
(!paramsOk ? paramsRes.message : null) ||
'일부 항목 저장에 실패했습니다.';
setSaveMessage({ type: 'error', text: errMsg });
}
} catch {
setSaveMessage({ type: 'error', text: '저장 중 오류가 발생했습니다.' });
} finally {
setSaving(false);
setTimeout(() => setSaveMessage(null), 3000);
}
};
const handleDelete = async () => {
if (!window.confirm('이 API를 삭제하시겠습니까?')) return;
try {
const res = await deleteServiceApi(serviceId, apiId);
if (res.success) {
navigate('/admin/apis');
} else {
setSaveMessage({ type: 'error', text: res.message || 'API 삭제에 실패했습니다.' });
}
} catch {
setSaveMessage({ type: 'error', text: 'API 삭제 중 오류가 발생했습니다.' });
}
};
// Loading
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<div className="w-8 h-8 border-4 border-blue-500 border-t-transparent rounded-full animate-spin" />
</div>
);
}
// Not found
if (notFound) {
return (
<div className="max-w-5xl mx-auto text-center py-20">
<p className="text-gray-500 dark:text-gray-400 text-lg">API를 .</p>
<Link
to="/admin/apis"
className="mt-4 inline-block text-blue-600 dark:text-blue-400 hover:underline text-sm"
>
API
</Link>
</div>
);
}
// Error
if (error) {
return (
<div className="max-w-5xl mx-auto py-10">
<div className="p-4 bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-400 rounded-lg text-sm">
{error}
</div>
<Link
to="/admin/apis"
className="mt-4 inline-block text-blue-600 dark:text-blue-400 hover:underline text-sm"
>
API
</Link>
</div>
);
}
return (
<div className="max-w-5xl mx-auto">
{/* Breadcrumb */}
<nav className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 mb-4">
<Link
to="/admin/apis"
className="hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
>
API
</Link>
<span>/</span>
<span className="text-gray-700 dark:text-gray-300">{serviceName || `서비스 #${serviceId}`}</span>
<span>/</span>
<span className="text-gray-900 dark:text-gray-100 font-medium">{apiName || `API #${apiId}`}</span>
</nav>
{/* Header */}
<div className="flex items-start justify-between mb-2">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">{apiName}</h1>
<div className="flex items-center gap-2 mt-1.5">
<span
className={`inline-block px-2 py-0.5 rounded text-xs font-bold ${
METHOD_COLOR[apiMethod] ?? 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
}`}
>
{apiMethod}
</span>
<span className="font-mono text-sm text-gray-600 dark:text-gray-400">{apiPath}</span>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<button
onClick={handleDelete}
className="px-4 py-2 rounded-lg bg-red-600 hover:bg-red-700 text-white text-sm font-medium transition-colors"
>
</button>
<button
onClick={handleSave}
disabled={saving}
className="px-4 py-2 rounded-lg bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white text-sm font-medium transition-colors"
>
{saving ? '저장 중...' : '저장'}
</button>
</div>
</div>
{/* Save message */}
{saveMessage && (
<div
className={`mb-4 p-3 rounded-lg text-sm ${
saveMessage.type === 'success'
? 'bg-green-50 dark:bg-green-900/30 text-green-700 dark:text-green-400'
: 'bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-400'
}`}
>
{saveMessage.text}
</div>
)}
{/* Sections */}
<div className="space-y-6 mt-6">
{/* Section 1: 기본 정보 */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100 mb-4"> </h2>
<div className="grid grid-cols-2 gap-4 mb-4">
<div>
<label className={LABEL_CLS}>Method</label>
<select
value={apiMethod}
onChange={(e) => setApiMethod(e.target.value)}
className={INPUT_CLS}
>
{['GET', 'POST', 'PUT', 'DELETE'].map((m) => (
<option key={m} value={m}>{m}</option>
))}
</select>
</div>
<div>
<label className={LABEL_CLS}>API Path</label>
<input
type="text"
value={apiPath}
onChange={(e) => setApiPath(e.target.value)}
placeholder="/api/v1/example"
className={`${INPUT_CLS} font-mono`}
/>
</div>
<div>
<label className={LABEL_CLS}>API명</label>
<input
type="text"
value={apiName}
onChange={(e) => setApiName(e.target.value)}
placeholder="API 이름"
className={INPUT_CLS}
/>
</div>
<div>
<label className={LABEL_CLS}>Domain</label>
<select
value={apiDomain}
onChange={(e) => setApiDomain(e.target.value)}
className={INPUT_CLS}
>
<option value=""> </option>
{domains.map((d) => (
<option key={d.domainId} value={d.domainName}>{d.domainName}</option>
))}
</select>
</div>
<div>
<label className={LABEL_CLS}>Section</label>
<input
type="text"
value={apiSection}
onChange={(e) => setApiSection(e.target.value)}
placeholder="섹션 (선택)"
className={INPUT_CLS}
/>
</div>
<div className="flex items-center gap-2 pt-6">
<input
type="checkbox"
id="apiIsActive"
checked={apiIsActive}
onChange={(e) => setApiIsActive(e.target.checked)}
className="w-4 h-4 text-blue-600 rounded border-gray-300 dark:border-gray-600 focus:ring-blue-500"
/>
<label htmlFor="apiIsActive" className="text-sm font-medium text-gray-700 dark:text-gray-300">
Active
</label>
</div>
</div>
<div>
<label className={LABEL_CLS}></label>
<textarea
value={apiDescription}
onChange={(e) => setApiDescription(e.target.value)}
rows={3}
placeholder="API 설명 (선택)"
className={`${INPUT_CLS} resize-none`}
/>
</div>
</div>
{/* Section 2: API 명세 */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100 mb-4">API </h2>
<div className="space-y-4">
<div>
<label className={LABEL_CLS}> URL</label>
<input
type="text"
value={sampleUrl}
onChange={(e) => setSampleUrl(e.target.value)}
placeholder="https://example.com/api/v1/..."
className={INPUT_CLS}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<input
type="checkbox"
id="authRequired"
checked={authRequired}
onChange={(e) => setAuthRequired(e.target.checked)}
className="w-4 h-4 text-blue-600 rounded border-gray-300 dark:border-gray-600 focus:ring-blue-500"
/>
<label htmlFor="authRequired" className="text-sm font-medium text-gray-700 dark:text-gray-300">
</label>
</div>
<div>
<label className={LABEL_CLS}> </label>
<select
value={authType}
onChange={(e) => setAuthType(e.target.value)}
className={INPUT_CLS}
>
<option value=""></option>
<option value="API_KEY">API_KEY</option>
<option value="JWT">JWT</option>
<option value="NONE">NONE</option>
</select>
</div>
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<input
type="checkbox"
id="deprecated"
checked={deprecated}
onChange={(e) => setDeprecated(e.target.checked)}
className="w-4 h-4 text-blue-600 rounded border-gray-300 dark:border-gray-600 focus:ring-blue-500"
/>
<label htmlFor="deprecated" className="text-sm font-medium text-gray-700 dark:text-gray-300">
Deprecated
</label>
</div>
<div>
<label className={LABEL_CLS}> </label>
<input
type="text"
value={dataFormat}
onChange={(e) => setDataFormat(e.target.value)}
placeholder="JSON, XML 등"
className={INPUT_CLS}
/>
</div>
</div>
</div>
<div>
<label className={LABEL_CLS}> URL</label>
<input
type="text"
value={referenceUrl}
onChange={(e) => setReferenceUrl(e.target.value)}
placeholder="https://..."
className={INPUT_CLS}
/>
</div>
<div>
<label className={LABEL_CLS}></label>
<textarea
value={specNote}
onChange={(e) => setSpecNote(e.target.value)}
rows={2}
placeholder="추가 참고사항 (선택)"
className={`${INPUT_CLS} resize-none`}
/>
</div>
</div>
</div>
{/* Section 3: 요청인자 */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100"></h2>
<button
type="button"
onClick={() => addParam()}
className="px-3 py-1.5 rounded-lg bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 text-sm font-medium transition-colors"
>
</button>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 dark:border-gray-700">
<th className="px-2 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 w-10">#</th>
<th className="px-2 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400"></th>
<th className="px-2 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400"></th>
<th className="px-2 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400"></th>
<th className="px-2 py-2 text-center text-xs font-medium text-gray-500 dark:text-gray-400 w-14"></th>
<th className="px-2 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 w-28"></th>
<th className="px-2 py-2 w-10" />
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
{requestParams.length === 0 ? (
<tr>
<td
colSpan={7}
className="px-2 py-6 text-center text-gray-400 dark:text-gray-500 text-sm"
>
</td>
</tr>
) : (
requestParams.map((param, idx) => (
<tr key={idx}>
<td className="px-2 py-1.5 text-gray-500 dark:text-gray-400 text-center text-xs">
{idx + 1}
</td>
<td className="px-2 py-1.5">
<input
type="text"
value={param.paramName}
onChange={(e) => updateParam(idx, 'paramName', e.target.value)}
placeholder="paramName"
className={TABLE_INPUT_CLS}
/>
</td>
<td className="px-2 py-1.5">
<input
type="text"
value={param.paramMeaning ?? ''}
onChange={(e) =>
updateParam(idx, 'paramMeaning', e.target.value || undefined)
}
placeholder="의미"
className={TABLE_INPUT_CLS}
/>
</td>
<td className="px-2 py-1.5">
<input
type="text"
value={param.paramDescription ?? ''}
onChange={(e) =>
updateParam(idx, 'paramDescription', e.target.value || undefined)
}
placeholder="설명"
className={TABLE_INPUT_CLS}
/>
</td>
<td className="px-2 py-1.5 text-center">
<input
type="checkbox"
checked={param.required ?? false}
onChange={(e) => updateParam(idx, 'required', e.target.checked)}
className="w-4 h-4 text-blue-600 rounded border-gray-300 dark:border-gray-600 focus:ring-blue-500"
/>
</td>
<td className="px-2 py-1.5">
<select
value={param.inputType ?? 'TEXT'}
onChange={(e) => updateParam(idx, 'inputType', e.target.value)}
className={TABLE_INPUT_CLS}
>
<option value="TEXT">TEXT</option>
<option value="NUMBER">NUMBER</option>
<option value="DATE">DATE</option>
<option value="DATETIME">DATETIME</option>
</select>
</td>
<td className="px-2 py-1.5 text-center">
<button
type="button"
onClick={() => removeParam(idx)}
className="text-red-500 hover:text-red-700 dark:hover:text-red-400 font-bold text-base leading-none"
title="삭제"
>
×
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{/* Section 4: 출력결과 */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100"></h2>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setShowJsonInput((v) => !v)}
className="px-3 py-1.5 rounded-lg bg-indigo-100 hover:bg-indigo-200 dark:bg-indigo-900 dark:hover:bg-indigo-800 text-indigo-700 dark:text-indigo-300 text-sm font-medium transition-colors"
>
{showJsonInput ? 'JSON 닫기' : 'JSON 파싱'}
</button>
<button
type="button"
onClick={addResponseParam}
className="px-3 py-1.5 rounded-lg bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 text-sm font-medium transition-colors"
>
</button>
</div>
</div>
{showJsonInput && (
<div className="mb-4 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg border border-gray-200 dark:border-gray-600">
<p className="text-xs text-gray-500 dark:text-gray-400 mb-2">
JSON . ( )
</p>
<textarea
value={jsonInput}
onChange={(e) => { setJsonInput(e.target.value); setJsonError(null); }}
rows={6}
placeholder={'{\n "result": "success",\n "data": {\n "id": 1,\n "name": "example"\n }\n}'}
className={`${INPUT_CLS} font-mono resize-none`}
/>
{jsonError && (
<p className="mt-1.5 text-xs text-red-500">{jsonError}</p>
)}
<button
type="button"
onClick={parseJsonToParams}
disabled={!jsonInput.trim()}
className="mt-2 px-4 py-1.5 rounded-lg bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 text-white text-sm font-medium transition-colors"
>
</button>
</div>
)}
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 dark:border-gray-700">
<th className="px-2 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 w-10">#</th>
<th className="px-2 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400"></th>
<th className="px-2 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400">()</th>
<th className="px-2 py-2 w-10" />
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
{responseParams.length === 0 ? (
<tr>
<td
colSpan={4}
className="px-2 py-6 text-center text-gray-400 dark:text-gray-500 text-sm"
>
</td>
</tr>
) : (
responseParams.map((param, idx) => (
<tr key={idx}>
<td className="px-2 py-1.5 text-gray-500 dark:text-gray-400 text-center text-xs">
{idx + 1}
</td>
<td className="px-2 py-1.5">
<input
type="text"
value={param.paramName}
onChange={(e) => updateResponseParam(idx, 'paramName', e.target.value)}
placeholder="variableName"
className={TABLE_INPUT_CLS}
/>
</td>
<td className="px-2 py-1.5">
<input
type="text"
value={param.paramMeaning ?? ''}
onChange={(e) =>
updateResponseParam(idx, 'paramMeaning', e.target.value || undefined)
}
placeholder="의미(단위)"
className={TABLE_INPUT_CLS}
/>
</td>
<td className="px-2 py-1.5 text-center">
<button
type="button"
onClick={() => removeResponseParam(idx)}
className="text-red-500 hover:text-red-700 dark:hover:text-red-400 font-bold text-base leading-none"
title="삭제"
>
×
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
</div>
);
};
export default ApiEditPage;

파일 보기

@ -0,0 +1,430 @@
import { useState, useEffect, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import type { ServiceInfo, ServiceApi, CreateServiceApiRequest } from '../../types/service';
import { getServices, getServiceApis, createServiceApi } from '../../services/serviceService';
const METHOD_COLOR: Record<string, string> = {
GET: 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300',
POST: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300',
PUT: 'bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300',
DELETE: 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300',
};
interface FlatApi extends ServiceApi {
serviceName: string;
}
const ApisPage = () => {
const navigate = useNavigate();
const [services, setServices] = useState<ServiceInfo[]>([]);
const [allApis, setAllApis] = useState<FlatApi[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Filter state
const [filterServiceId, setFilterServiceId] = useState<number | 'all'>('all');
const [searchText, setSearchText] = useState('');
const [filterActive, setFilterActive] = useState<'all' | 'active' | 'inactive'>('all');
// Create API modal state
const [isModalOpen, setIsModalOpen] = useState(false);
const [modalError, setModalError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [modalServiceId, setModalServiceId] = useState<number | ''>('');
const [modalMethod, setModalMethod] = useState('GET');
const [modalPath, setModalPath] = useState('');
const [modalName, setModalName] = useState('');
const [modalDomain, setModalDomain] = useState('');
const [modalSection, setModalSection] = useState('');
const [modalDescription, setModalDescription] = useState('');
const fetchAll = async () => {
try {
setLoading(true);
setError(null);
const svcRes = await getServices();
if (!svcRes.success || !svcRes.data) {
setError(svcRes.message || '서비스 목록을 불러오는데 실패했습니다.');
return;
}
const loadedServices = svcRes.data;
setServices(loadedServices);
if (loadedServices.length === 0) {
setAllApis([]);
return;
}
const results = await Promise.allSettled(
loadedServices.map((svc) => getServiceApis(svc.serviceId)),
);
const flat: FlatApi[] = [];
results.forEach((result, idx) => {
if (result.status === 'fulfilled' && result.value.success && result.value.data) {
result.value.data.forEach((api) => {
flat.push({ ...api, serviceName: loadedServices[idx].serviceName });
});
}
});
setAllApis(flat);
} catch {
setError('API 목록을 불러오는데 실패했습니다.');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchAll();
}, []);
const filteredApis = useMemo(() => {
return allApis.filter((api) => {
if (filterServiceId !== 'all' && api.serviceId !== filterServiceId) return false;
if (filterActive === 'active' && !api.isActive) return false;
if (filterActive === 'inactive' && api.isActive) return false;
if (searchText.trim()) {
const q = searchText.trim().toLowerCase();
if (!api.apiName.toLowerCase().includes(q) && !api.apiPath.toLowerCase().includes(q)) {
return false;
}
}
return true;
});
}, [allApis, filterServiceId, filterActive, searchText]);
const handleOpenModal = () => {
setModalServiceId(services.length > 0 ? services[0].serviceId : '');
setModalMethod('GET');
setModalPath('');
setModalName('');
setModalDomain('');
setModalSection('');
setModalDescription('');
setModalError(null);
setIsModalOpen(true);
};
const handleCloseModal = () => {
setIsModalOpen(false);
setModalError(null);
};
const handleModalSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (modalServiceId === '') return;
setModalError(null);
setSubmitting(true);
try {
const req: CreateServiceApiRequest = {
apiMethod: modalMethod,
apiPath: modalPath,
apiName: modalName,
apiDomain: modalDomain || undefined,
apiSection: modalSection || undefined,
description: modalDescription || undefined,
};
const res = await createServiceApi(modalServiceId as number, req);
if (!res.success) {
setModalError(res.message || 'API 생성에 실패했습니다.');
return;
}
handleCloseModal();
await fetchAll();
if (res.data) {
navigate(`/admin/apis/${res.data.serviceId}/${res.data.apiId}`);
}
} catch {
setModalError('API 생성에 실패했습니다.');
} finally {
setSubmitting(false);
}
};
if (loading) {
return (
<div className="max-w-7xl mx-auto text-center py-10 text-gray-500 dark:text-gray-400">
...
</div>
);
}
return (
<div className="max-w-7xl mx-auto">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">API </h1>
<button
onClick={handleOpenModal}
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium"
>
API
</button>
</div>
{/* Global error */}
{error && (
<div className="mb-4 p-3 bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-400 rounded-lg text-sm">
{error}
</div>
)}
{/* Filters */}
<div className="flex flex-wrap gap-3 mb-4">
<select
value={filterServiceId}
onChange={(e) =>
setFilterServiceId(e.target.value === 'all' ? 'all' : Number(e.target.value))
}
className="px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="all"> </option>
{services.map((svc) => (
<option key={svc.serviceId} value={svc.serviceId}>
{svc.serviceName}
</option>
))}
</select>
<input
type="text"
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
placeholder="API명, Path 검색"
className="px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 min-w-[200px]"
/>
<div className="flex rounded-lg border border-gray-300 dark:border-gray-600 overflow-hidden text-sm">
{(['all', 'active', 'inactive'] as const).map((v) => (
<button
key={v}
onClick={() => setFilterActive(v)}
className={`px-3 py-2 font-medium ${
filterActive === v
? 'bg-blue-600 text-white'
: 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700'
}`}
>
{v === 'all' ? '전체' : v === 'active' ? 'Active' : 'Inactive'}
</button>
))}
</div>
</div>
{/* Table */}
<div className="overflow-x-auto bg-white dark:bg-gray-800 rounded-lg shadow">
<table className="w-full divide-y divide-gray-200 dark:divide-gray-700 text-sm">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400"></th>
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Method</th>
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Path</th>
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">API명</th>
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Domain</th>
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Section</th>
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Active</th>
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{filteredApis.map((api) => (
<tr
key={`${api.serviceId}-${api.apiId}`}
onClick={() => navigate(`/admin/apis/${api.serviceId}/${api.apiId}`)}
className="cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700"
>
<td className="px-4 py-3 text-gray-700 dark:text-gray-300">{api.serviceName}</td>
<td className="px-4 py-3">
<span
className={`inline-block px-2 py-0.5 rounded text-xs font-bold ${
METHOD_COLOR[api.apiMethod] ?? 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
}`}
>
{api.apiMethod}
</span>
</td>
<td className="px-4 py-3 font-mono text-gray-800 dark:text-gray-200 truncate max-w-[240px]">
{api.apiPath}
</td>
<td className="px-4 py-3 text-gray-900 dark:text-gray-100">{api.apiName}</td>
<td className="px-4 py-3 text-gray-500 dark:text-gray-400">{api.apiDomain || '-'}</td>
<td className="px-4 py-3 text-gray-500 dark:text-gray-400">{api.apiSection || '-'}</td>
<td className="px-4 py-3">
<span
className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${
api.isActive
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300'
: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300'
}`}
>
{api.isActive ? 'Active' : 'Inactive'}
</span>
</td>
<td className="px-4 py-3">
<span className="text-gray-400 dark:text-gray-500">-</span>
</td>
</tr>
))}
{filteredApis.length === 0 && (
<tr>
<td colSpan={8} className="px-4 py-8 text-center text-gray-400 dark:text-gray-500">
API가
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* Create API Modal */}
{isModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl w-full max-w-lg mx-4">
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">API </h2>
<button
onClick={handleCloseModal}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 text-xl font-bold leading-none"
>
×
</button>
</div>
<form onSubmit={handleModalSubmit} className="px-6 py-4 space-y-4">
{modalError && (
<div className="p-3 bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-400 rounded-lg text-sm">
{modalError}
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
<span className="text-red-500">*</span>
</label>
<select
value={modalServiceId}
onChange={(e) => setModalServiceId(Number(e.target.value))}
required
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
{services.map((svc) => (
<option key={svc.serviceId} value={svc.serviceId}>
{svc.serviceName}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Method <span className="text-red-500">*</span>
</label>
<select
value={modalMethod}
onChange={(e) => setModalMethod(e.target.value)}
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
{['GET', 'POST', 'PUT', 'DELETE'].map((m) => (
<option key={m} value={m}>{m}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
API Path <span className="text-red-500">*</span>
</label>
<input
type="text"
value={modalPath}
onChange={(e) => setModalPath(e.target.value)}
required
placeholder="/api/v1/example"
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
API명 <span className="text-red-500">*</span>
</label>
<input
type="text"
value={modalName}
onChange={(e) => setModalName(e.target.value)}
required
placeholder="API 이름"
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Domain
</label>
<input
type="text"
value={modalDomain}
onChange={(e) => setModalDomain(e.target.value)}
placeholder="도메인 (선택)"
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Section
</label>
<input
type="text"
value={modalSection}
onChange={(e) => setModalSection(e.target.value)}
placeholder="섹션 (선택)"
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
</label>
<textarea
value={modalDescription}
onChange={(e) => setModalDescription(e.target.value)}
rows={3}
placeholder="API 설명 (선택)"
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
/>
</div>
<div className="flex justify-end gap-3 pt-2">
<button
type="button"
onClick={handleCloseModal}
className="px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 text-sm font-medium hover:bg-gray-50 dark:hover:bg-gray-700"
>
</button>
<button
type="submit"
disabled={submitting}
className="px-4 py-2 rounded-lg bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white text-sm font-medium"
>
{submitting ? '생성 중...' : '생성'}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
};
export default ApisPage;

파일 보기

@ -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>
);
};

파일 보기

@ -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}`);

파일 보기

@ -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;
}

파일 보기

@ -4,16 +4,19 @@ 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 +24,7 @@ import java.util.List;
public class ApiHubController {
private final ApiHubService apiHubService;
private final ServiceManagementService serviceManagementService;
/**
* 활성 서비스와 해당 서비스의 활성 API를 도메인별로 그룹화하여 카탈로그 형태로 반환
@ -39,4 +43,24 @@ public class ApiHubController {
List<RecentApiResponse> recentApis = apiHubService.getRecentApis();
return ResponseEntity.ok(ApiResponse.ok(recentApis));
}
/**
* 서비스 단건 카탈로그 조회
*/
@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));
}
}

파일 보기

@ -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";
}

파일 보기

@ -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,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,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,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,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 생성
*/