325 lines
17 KiB
TypeScript
325 lines
17 KiB
TypeScript
import { useState, useEffect, useMemo } from 'react';
|
|
|
|
interface BypassParam {
|
|
paramName: string;
|
|
paramType: string;
|
|
paramIn: string;
|
|
required: boolean;
|
|
description: string;
|
|
example: string;
|
|
}
|
|
|
|
interface BypassConfig {
|
|
id: number;
|
|
domainName: string;
|
|
endpointName: string;
|
|
displayName: string;
|
|
webclientBean: string;
|
|
httpMethod: string;
|
|
externalPath: string;
|
|
description: string;
|
|
generated: boolean;
|
|
createdAt: string;
|
|
params: BypassParam[];
|
|
}
|
|
|
|
interface ApiResponse<T> {
|
|
success: boolean;
|
|
data: T;
|
|
}
|
|
|
|
type ViewMode = 'card' | 'table';
|
|
|
|
const METHOD_COLORS: Record<string, string> = {
|
|
GET: 'bg-emerald-100 text-emerald-700',
|
|
POST: 'bg-blue-100 text-blue-700',
|
|
PUT: 'bg-amber-100 text-amber-700',
|
|
DELETE: 'bg-red-100 text-red-700',
|
|
};
|
|
|
|
const SWAGGER_GROUP_MAP: Record<string, string> = {
|
|
maritimeApiWebClient: '3-1.%20Ships%20API',
|
|
maritimeAisApiWebClient: '3-2.%20AIS%20API',
|
|
maritimeServiceApiWebClient: '3-3.%20Web%20Services%20API',
|
|
};
|
|
|
|
function buildSwaggerDeepLink(config: BypassConfig): string {
|
|
const group = SWAGGER_GROUP_MAP[config.webclientBean] ?? '3-1.%20Ships%20API';
|
|
const base = `/snp-global/swagger-ui/index.html?urls.primaryName=${group}`;
|
|
const tag = config.domainName.charAt(0).toUpperCase() + config.domainName.slice(1);
|
|
const operationId = `get${config.endpointName}Data`;
|
|
return `${base}#/${tag}/${operationId}`;
|
|
}
|
|
|
|
export default function BypassCatalog() {
|
|
const [configs, setConfigs] = useState<BypassConfig[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
const [selectedDomain, setSelectedDomain] = useState('');
|
|
const [viewMode, setViewMode] = useState<ViewMode>('table');
|
|
|
|
useEffect(() => {
|
|
fetch('/snp-global/api/bypass-config')
|
|
.then(res => res.json())
|
|
.then((res: ApiResponse<BypassConfig[]>) => setConfigs((res.data ?? []).filter(c => c.generated)))
|
|
.catch(() => setConfigs([]))
|
|
.finally(() => setLoading(false));
|
|
}, []);
|
|
|
|
const domainNames = useMemo(() => {
|
|
const names = [...new Set(configs.map((c) => c.domainName))];
|
|
return names.sort();
|
|
}, [configs]);
|
|
|
|
const filtered = useMemo(() => {
|
|
return configs.filter((c) => {
|
|
const matchesSearch =
|
|
!searchTerm.trim() ||
|
|
c.domainName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
c.displayName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
(c.description || '').toLowerCase().includes(searchTerm.toLowerCase());
|
|
const matchesDomain = !selectedDomain || c.domainName === selectedDomain;
|
|
return matchesSearch && matchesDomain;
|
|
});
|
|
}, [configs, searchTerm, selectedDomain]);
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex items-center justify-center py-20 text-wing-muted">
|
|
<div className="text-sm">API 목록을 불러오는 중...</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* 헤더 */}
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-wing-text">S&P Global API 카탈로그</h1>
|
|
<p className="mt-1 text-sm text-wing-muted">
|
|
S&P Global Maritime API 목록입니다. Swagger UI에서 직접 테스트할 수 있습니다.
|
|
</p>
|
|
</div>
|
|
<a
|
|
href="/snp-global/swagger-ui/index.html"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="px-4 py-2 text-sm font-medium text-white bg-wing-accent hover:bg-wing-accent/80 rounded-lg transition-colors no-underline"
|
|
>
|
|
Swagger UI
|
|
</a>
|
|
</div>
|
|
|
|
{/* 검색 + 필터 + 뷰 전환 */}
|
|
<div className="bg-wing-surface rounded-xl shadow-md p-4">
|
|
<div className="flex gap-3 items-center flex-wrap">
|
|
{/* 검색 */}
|
|
<div className="relative flex-1 min-w-[200px]">
|
|
<span className="absolute inset-y-0 left-3 flex items-center text-wing-muted">
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
|
</svg>
|
|
</span>
|
|
<input
|
|
type="text"
|
|
placeholder="도메인명, 표시명으로 검색..."
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
className="w-full pl-10 pr-4 py-2 border border-wing-border rounded-lg text-sm
|
|
focus:ring-2 focus:ring-wing-accent focus:border-wing-accent outline-none bg-wing-surface text-wing-text"
|
|
/>
|
|
{searchTerm && (
|
|
<button
|
|
onClick={() => setSearchTerm('')}
|
|
className="absolute inset-y-0 right-3 flex items-center text-wing-muted hover:text-wing-text"
|
|
>
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* 도메인 드롭다운 필터 */}
|
|
<select
|
|
value={selectedDomain}
|
|
onChange={(e) => setSelectedDomain(e.target.value)}
|
|
className="px-3 py-2 text-sm rounded-lg border border-wing-border bg-wing-surface text-wing-text"
|
|
>
|
|
<option value="">전체 도메인</option>
|
|
{domainNames.map((name) => (
|
|
<option key={name} value={name}>{name}</option>
|
|
))}
|
|
</select>
|
|
|
|
{/* 뷰 전환 토글 */}
|
|
<div className="flex rounded-lg border border-wing-border overflow-hidden">
|
|
<button
|
|
onClick={() => setViewMode('table')}
|
|
title="테이블 보기"
|
|
className={`px-3 py-2 transition-colors ${
|
|
viewMode === 'table'
|
|
? 'bg-wing-accent text-white'
|
|
: 'bg-wing-surface text-wing-muted hover:text-wing-text'
|
|
}`}
|
|
>
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
|
</svg>
|
|
</button>
|
|
<button
|
|
onClick={() => setViewMode('card')}
|
|
title="카드 보기"
|
|
className={`px-3 py-2 transition-colors border-l border-wing-border ${
|
|
viewMode === 'card'
|
|
? 'bg-wing-accent text-white'
|
|
: 'bg-wing-surface text-wing-muted hover:text-wing-text'
|
|
}`}
|
|
>
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{(searchTerm || selectedDomain) && (
|
|
<p className="mt-2 text-xs text-wing-muted">
|
|
{filtered.length}개 API 검색됨
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* 빈 상태 */}
|
|
{configs.length === 0 ? (
|
|
<div className="py-16 text-center text-wing-muted border border-dashed border-wing-border rounded-xl bg-wing-card">
|
|
<p className="text-base font-medium mb-1">등록된 API가 없습니다.</p>
|
|
<p className="text-sm">관리자에게 문의해주세요.</p>
|
|
</div>
|
|
) : filtered.length === 0 ? (
|
|
<div className="py-16 text-center text-wing-muted border border-dashed border-wing-border rounded-xl bg-wing-card">
|
|
<p className="text-base font-medium mb-1">검색 결과가 없습니다.</p>
|
|
<p className="text-sm">다른 검색어를 사용해 보세요.</p>
|
|
</div>
|
|
) : viewMode === 'card' ? (
|
|
/* 카드 뷰 */
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
{filtered.map((config) => (
|
|
<div
|
|
key={config.id}
|
|
className="bg-wing-card border border-wing-border rounded-xl p-5 flex flex-col gap-3 hover:border-wing-accent/40 transition-colors"
|
|
>
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="min-w-0">
|
|
<p className="text-sm font-semibold text-wing-text truncate">{config.displayName}</p>
|
|
<p className="text-xs text-wing-muted font-mono mt-0.5">{config.domainName}</p>
|
|
</div>
|
|
<span className={['shrink-0 px-1.5 py-0.5 text-xs font-bold rounded', METHOD_COLORS[config.httpMethod] ?? 'bg-wing-card text-wing-muted'].join(' ')}>
|
|
{config.httpMethod}
|
|
</span>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<p className="text-xs text-wing-muted font-mono truncate">{config.externalPath}</p>
|
|
{config.description && (
|
|
<p className="text-xs text-wing-muted line-clamp-2">{config.description}</p>
|
|
)}
|
|
</div>
|
|
{config.params.length > 0 && (
|
|
<div>
|
|
<div className="text-[10px] text-wing-muted mb-1 font-medium">Parameters</div>
|
|
<div className="flex flex-wrap gap-1">
|
|
{config.params.map((p) => (
|
|
<span
|
|
key={p.paramName}
|
|
className="text-[10px] px-1.5 py-0.5 rounded bg-wing-surface text-wing-muted"
|
|
title={`${p.paramIn} · ${p.paramType}${p.required ? ' · 필수' : ''}`}
|
|
>
|
|
{p.paramName}
|
|
{p.required && <span className="text-red-400 ml-0.5">*</span>}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
<div className="pt-1 border-t border-wing-border mt-auto">
|
|
<a
|
|
href={buildSwaggerDeepLink(config)}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-xs font-medium text-blue-500 hover:text-blue-600 no-underline transition-colors"
|
|
>
|
|
Swagger에서 테스트 →
|
|
</a>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
/* 테이블 뷰 */
|
|
<div className="bg-wing-surface rounded-xl shadow-md overflow-hidden">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b border-wing-border bg-wing-card">
|
|
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">도메인명</th>
|
|
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">표시명</th>
|
|
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">HTTP</th>
|
|
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">외부 경로</th>
|
|
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">파라미터</th>
|
|
<th className="text-right px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">Swagger</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-wing-border">
|
|
{filtered.map((config) => (
|
|
<tr key={config.id} className="hover:bg-wing-hover transition-colors">
|
|
<td className="px-4 py-3 font-mono text-xs text-wing-text">{config.domainName}</td>
|
|
<td className="px-4 py-3 font-medium text-wing-text">{config.displayName}</td>
|
|
<td className="px-4 py-3">
|
|
<span className={['px-1.5 py-0.5 text-xs font-bold rounded', METHOD_COLORS[config.httpMethod] ?? 'bg-wing-card text-wing-muted'].join(' ')}>
|
|
{config.httpMethod}
|
|
</span>
|
|
</td>
|
|
<td className="px-4 py-3 font-mono text-xs text-wing-muted max-w-[250px] truncate" title={config.externalPath}>
|
|
{config.externalPath}
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<div className="flex flex-wrap gap-1">
|
|
{config.params.map((p) => (
|
|
<span
|
|
key={p.paramName}
|
|
className="text-[10px] px-1.5 py-0.5 rounded bg-wing-card text-wing-muted"
|
|
title={`${p.paramIn} · ${p.paramType}${p.required ? ' · 필수' : ''}`}
|
|
>
|
|
{p.paramName}
|
|
{p.required && <span className="text-red-400 ml-0.5">*</span>}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</td>
|
|
<td className="px-4 py-3 text-right">
|
|
<a
|
|
href={buildSwaggerDeepLink(config)}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-xs font-medium text-blue-500 hover:text-blue-600 no-underline transition-colors"
|
|
>
|
|
테스트 →
|
|
</a>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|