Files
sam-manage/resources/views/dev-tools/api-explorer/index.blade.php
hskwon bfbf4d3225 feat(api-explorer): Phase 1 기본 구조 및 OpenAPI 파싱 구현
- Config: api-explorer.php (환경, 보안, 캐시 설정)
- Migration: api_bookmarks, api_templates, api_histories, api_environments
- Model: ApiBookmark, ApiTemplate, ApiHistory, ApiEnvironment
- Service: OpenApiParserService, ApiRequestService, ApiExplorerService
- Controller: ApiExplorerController (CRUD, 실행, 히스토리)
- View: 3-Panel 레이아웃 (sidebar, request, response, history)
- Route: 23개 엔드포인트 등록

Swagger UI 대체 개발 도구, HTMX 기반 SPA 경험
2025-12-17 21:06:41 +09:00

580 lines
20 KiB
PHP

@extends('layouts.app')
@section('title', 'API Explorer')
@push('styles')
<style>
/* 3-Panel 레이아웃 */
.api-explorer {
display: grid;
grid-template-columns: 280px 1fr 1fr;
height: calc(100vh - 120px);
gap: 0;
}
/* 사이드바 */
.api-sidebar {
border-right: 1px solid #e5e7eb;
display: flex;
flex-direction: column;
overflow: hidden;
}
.api-sidebar-header {
padding: 1rem;
border-bottom: 1px solid #e5e7eb;
flex-shrink: 0;
}
.api-sidebar-content {
flex: 1;
overflow-y: auto;
}
/* 요청/응답 패널 */
.api-request-panel,
.api-response-panel {
display: flex;
flex-direction: column;
overflow: hidden;
}
.api-request-panel {
border-right: 1px solid #e5e7eb;
}
.panel-header {
padding: 1rem;
border-bottom: 1px solid #e5e7eb;
flex-shrink: 0;
}
.panel-content {
flex: 1;
overflow-y: auto;
padding: 1rem;
}
/* HTTP 메서드 배지 */
.method-badge {
display: inline-flex;
align-items: center;
padding: 0.125rem 0.5rem;
font-size: 0.75rem;
font-weight: 600;
border-radius: 0.25rem;
text-transform: uppercase;
}
.method-get { background: #dcfce7; color: #166534; }
.method-post { background: #dbeafe; color: #1e40af; }
.method-put { background: #fef3c7; color: #92400e; }
.method-patch { background: #ffedd5; color: #9a3412; }
.method-delete { background: #fee2e2; color: #991b1b; }
/* 상태 코드 */
.status-2xx { color: #16a34a; }
.status-3xx { color: #2563eb; }
.status-4xx { color: #ca8a04; }
.status-5xx { color: #dc2626; }
/* 엔드포인트 아이템 */
.endpoint-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
cursor: pointer;
transition: background-color 0.15s;
}
.endpoint-item:hover {
background: #f3f4f6;
}
.endpoint-item.active {
background: #eff6ff;
border-left: 3px solid #3b82f6;
}
.endpoint-path {
flex: 1;
font-size: 0.875rem;
color: #374151;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 태그 그룹 */
.tag-group {
margin-bottom: 0.5rem;
}
.tag-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 1rem;
font-weight: 600;
font-size: 0.75rem;
text-transform: uppercase;
color: #6b7280;
cursor: pointer;
}
.tag-header:hover {
background: #f9fafb;
}
/* JSON 에디터 */
.json-editor {
width: 100%;
min-height: 200px;
font-family: 'Monaco', 'Menlo', monospace;
font-size: 0.875rem;
padding: 0.75rem;
border: 1px solid #e5e7eb;
border-radius: 0.375rem;
resize: vertical;
}
/* 리사이즈 핸들 */
.resize-handle {
width: 4px;
background: transparent;
cursor: col-resize;
transition: background-color 0.15s;
}
.resize-handle:hover {
background: #3b82f6;
}
/* 반응형 */
@media (max-width: 1280px) {
.api-explorer {
grid-template-columns: 250px 1fr;
}
.api-response-panel {
display: none;
}
}
@media (max-width: 768px) {
.api-explorer {
grid-template-columns: 1fr;
grid-template-rows: auto 1fr;
}
.api-sidebar {
max-height: 40vh;
}
}
</style>
@endpush
@section('content')
<!-- 페이지 헤더 -->
<div class="flex justify-between items-center mb-4">
<div>
<h1 class="text-2xl font-bold text-gray-800">API Explorer</h1>
<p class="text-sm text-gray-500">OpenAPI 기반 API 테스트 도구</p>
</div>
<div class="flex items-center gap-3">
<!-- 환경 선택 -->
<select id="environment-select" class="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
@foreach($environments as $env)
<option value="{{ $env->id }}"
data-base-url="{{ $env->base_url }}"
data-api-key="{{ $env->decrypted_api_key }}"
{{ $env->is_default ? 'selected' : '' }}>
{{ $env->name }}
</option>
@endforeach
</select>
<!-- 히스토리 버튼 -->
<button onclick="toggleHistoryDrawer()" class="p-2 text-gray-600 hover:bg-gray-100 rounded-lg" title="히스토리">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</button>
<!-- 설정 버튼 -->
<button onclick="openSettingsModal()" class="p-2 text-gray-600 hover:bg-gray-100 rounded-lg" title="설정">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</button>
</div>
</div>
<!-- 3-Panel 레이아웃 -->
<div class="api-explorer bg-white rounded-lg shadow-sm overflow-hidden">
<!-- 사이드바: API 목록 -->
<div class="api-sidebar">
<div class="api-sidebar-header">
<!-- 검색 -->
<input type="text"
id="search-input"
placeholder="API 검색..."
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
hx-get="{{ route('api-explorer.endpoints') }}"
hx-trigger="keyup changed delay:300ms"
hx-target="#endpoint-list"
hx-include="#method-filters, #tag-filters">
<!-- 메서드 필터 -->
<div id="method-filters" class="flex flex-wrap gap-1 mt-2">
@foreach(['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] as $method)
<label class="inline-flex items-center">
<input type="checkbox" name="methods[]" value="{{ $method }}" class="hidden method-filter">
<span class="method-badge method-{{ strtolower($method) }} opacity-40 cursor-pointer hover:opacity-100 transition-opacity">
{{ $method }}
</span>
</label>
@endforeach
</div>
</div>
<div class="api-sidebar-content" id="endpoint-list">
@include('dev-tools.api-explorer.partials.sidebar', [
'endpointsByTag' => $endpoints,
'bookmarks' => $bookmarks
])
</div>
</div>
<!-- 요청 패널 -->
<div class="api-request-panel" id="request-panel">
<div class="panel-header">
<h3 class="font-semibold text-gray-700">요청</h3>
</div>
<div class="panel-content">
<div class="text-center text-gray-400 py-12">
<svg class="w-12 h-12 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<p>왼쪽에서 API를 선택하세요</p>
</div>
</div>
</div>
<!-- 응답 패널 -->
<div class="api-response-panel" id="response-panel">
<div class="panel-header flex items-center justify-between">
<h3 class="font-semibold text-gray-700">응답</h3>
<div id="response-meta" class="text-sm text-gray-500"></div>
</div>
<div class="panel-content">
<div id="response-content" class="text-center text-gray-400 py-12">
<svg class="w-12 h-12 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<p>API를 실행하면 응답이 여기에 표시됩니다</p>
</div>
</div>
</div>
</div>
<!-- 히스토리 서랍 (오버레이) -->
<div id="history-drawer" class="fixed inset-y-0 right-0 w-96 bg-white shadow-xl transform translate-x-full transition-transform duration-300 z-50">
<div class="h-full flex flex-col">
<div class="p-4 border-b flex items-center justify-between">
<h3 class="font-semibold text-gray-700">요청 히스토리</h3>
<div class="flex items-center gap-2">
<button onclick="clearHistory()" class="text-sm text-red-600 hover:text-red-700">전체 삭제</button>
<button onclick="toggleHistoryDrawer()" class="p-1 hover:bg-gray-100 rounded">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
<div id="history-content" class="flex-1 overflow-y-auto">
<!-- HTMX로 로드 -->
</div>
</div>
</div>
<div id="history-overlay" class="fixed inset-0 bg-black bg-opacity-25 hidden z-40" onclick="toggleHistoryDrawer()"></div>
@endsection
@push('scripts')
<script>
// 현재 선택된 엔드포인트
let currentEndpoint = null;
// 환경 설정
function getSelectedEnvironment() {
const select = document.getElementById('environment-select');
const option = select.options[select.selectedIndex];
return {
id: select.value,
name: option.text,
baseUrl: option.dataset.baseUrl,
apiKey: option.dataset.apiKey
};
}
// 엔드포인트 선택
function selectEndpoint(operationId, element) {
// 활성 상태 변경
document.querySelectorAll('.endpoint-item').forEach(el => el.classList.remove('active'));
element.classList.add('active');
// 요청 패널 로드
htmx.ajax('GET', `/dev-tools/api-explorer/endpoints/${operationId}`, {
target: '#request-panel',
swap: 'innerHTML'
});
}
// API 실행
async function executeApi(event) {
event.preventDefault();
const form = event.target;
const formData = new FormData(form);
const env = getSelectedEnvironment();
// 요청 데이터 구성
const method = formData.get('method');
let url = env.baseUrl + formData.get('endpoint');
// 경로 파라미터 치환
const pathParams = {};
formData.forEach((value, key) => {
if (key.startsWith('path_')) {
const paramName = key.replace('path_', '');
pathParams[paramName] = value;
url = url.replace(`{${paramName}}`, value);
}
});
// 쿼리 파라미터
const queryParams = {};
formData.forEach((value, key) => {
if (key.startsWith('query_') && value) {
queryParams[key.replace('query_', '')] = value;
}
});
// 헤더
const headers = {
'Content-Type': 'application/json',
'Accept': 'application/json'
};
if (env.apiKey) {
headers['X-API-KEY'] = env.apiKey;
}
// 바디
let body = null;
const bodyText = formData.get('body');
if (bodyText) {
try {
body = JSON.parse(bodyText);
} catch (e) {
showToast('JSON 형식이 올바르지 않습니다.', 'error');
return;
}
}
// 로딩 표시
const submitBtn = form.querySelector('button[type="submit"]');
const originalText = submitBtn.innerHTML;
submitBtn.innerHTML = '<svg class="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg> 실행 중...';
submitBtn.disabled = true;
try {
const response = await fetch('{{ route("api-explorer.execute") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}'
},
body: JSON.stringify({
method: method,
url: url,
headers: headers,
query: queryParams,
body: body,
environment: env.name
})
});
const result = await response.json();
displayResponse(result);
} catch (error) {
displayResponse({
status: 0,
headers: {},
body: { error: true, message: error.message },
duration_ms: 0
});
} finally {
submitBtn.innerHTML = originalText;
submitBtn.disabled = false;
}
}
// 응답 표시
function displayResponse(result) {
const meta = document.getElementById('response-meta');
const content = document.getElementById('response-content');
// 메타 정보
const statusClass = result.status >= 200 && result.status < 300 ? 'status-2xx' :
result.status >= 300 && result.status < 400 ? 'status-3xx' :
result.status >= 400 && result.status < 500 ? 'status-4xx' :
result.status >= 500 ? 'status-5xx' : 'text-gray-500';
meta.innerHTML = `
<span class="${statusClass} font-semibold">${result.status || 'Error'}</span>
<span class="mx-2">·</span>
<span>${result.duration_ms}ms</span>
`;
// 본문
const bodyStr = typeof result.body === 'object'
? JSON.stringify(result.body, null, 2)
: result.body;
content.innerHTML = `
<div class="space-y-4">
<!-- 헤더 -->
<div>
<h4 class="text-sm font-medium text-gray-700 mb-2">응답 헤더</h4>
<div class="bg-gray-50 rounded p-3 text-xs font-mono overflow-x-auto">
${Object.entries(result.headers || {}).map(([k, v]) =>
`<div><span class="text-gray-500">${k}:</span> ${Array.isArray(v) ? v.join(', ') : v}</div>`
).join('')}
</div>
</div>
<!-- 본문 -->
<div>
<div class="flex items-center justify-between mb-2">
<h4 class="text-sm font-medium text-gray-700">응답 본문</h4>
<button onclick="copyToClipboard(this.dataset.content)" data-content="${encodeURIComponent(bodyStr)}"
class="text-xs text-blue-600 hover:text-blue-700">
복사
</button>
</div>
<pre class="bg-gray-900 text-gray-100 rounded p-3 text-xs font-mono overflow-x-auto max-h-96">${escapeHtml(bodyStr)}</pre>
</div>
</div>
`;
}
// 히스토리 서랍 토글
function toggleHistoryDrawer() {
const drawer = document.getElementById('history-drawer');
const overlay = document.getElementById('history-overlay');
if (drawer.classList.contains('translate-x-full')) {
drawer.classList.remove('translate-x-full');
overlay.classList.remove('hidden');
// 히스토리 로드
htmx.ajax('GET', '{{ route("api-explorer.history") }}', {
target: '#history-content',
swap: 'innerHTML'
});
} else {
drawer.classList.add('translate-x-full');
overlay.classList.add('hidden');
}
}
// 히스토리 삭제
async function clearHistory() {
if (!confirm('모든 히스토리를 삭제하시겠습니까?')) return;
await fetch('{{ route("api-explorer.history.clear") }}', {
method: 'DELETE',
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}'
}
});
document.getElementById('history-content').innerHTML =
'<div class="text-center text-gray-400 py-8">히스토리가 없습니다.</div>';
}
// 즐겨찾기 토글
async function toggleBookmark(endpoint, method, button) {
const response = await fetch('{{ route("api-explorer.bookmarks.add") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}'
},
body: JSON.stringify({ endpoint, method })
});
const result = await response.json();
if (result.action === 'added') {
button.classList.add('text-yellow-500');
button.classList.remove('text-gray-400');
} else {
button.classList.remove('text-yellow-500');
button.classList.add('text-gray-400');
}
// 사이드바 새로고침
htmx.ajax('GET', '{{ route("api-explorer.endpoints") }}', {
target: '#endpoint-list',
swap: 'innerHTML'
});
}
// 메서드 필터 토글
document.querySelectorAll('.method-filter').forEach(checkbox => {
checkbox.addEventListener('change', function() {
const badge = this.nextElementSibling;
if (this.checked) {
badge.classList.remove('opacity-40');
} else {
badge.classList.add('opacity-40');
}
// 필터 적용
htmx.trigger('#search-input', 'keyup');
});
});
// 유틸리티
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function copyToClipboard(content) {
navigator.clipboard.writeText(decodeURIComponent(content)).then(() => {
showToast('클립보드에 복사되었습니다.');
});
}
// 태그 그룹 토글
function toggleTagGroup(tagName) {
const group = document.getElementById(`tag-${tagName}`);
const chevron = document.getElementById(`chevron-${tagName}`);
if (group.classList.contains('hidden')) {
group.classList.remove('hidden');
chevron.classList.add('rotate-90');
} else {
group.classList.add('hidden');
chevron.classList.remove('rotate-90');
}
}
</script>
@endpush