- 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 경험
580 lines
20 KiB
PHP
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
|