Files
sam-manage/resources/views/dev-tools/api-explorer/index.blade.php
kent 5b8ecf02ab fix(dev-tools): HTMX 호환성 문제 수정
- api-logs: let→var 변경으로 스크립트 재실행 오류 해결
- api-explorer: @push('styles')→content 내 style로 hx-boost 호환

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 01:04:50 +09:00

1288 lines
49 KiB
PHP

@extends('layouts.app')
@section('title', 'API Explorer')
@section('content')
{{-- hx-boost 네비게이션에서도 스타일이 적용되도록 content 안에 포함 --}}
<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>
<!-- 페이지 헤더 -->
<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 }}"
data-auth-token="{{ $env->decrypted_auth_token }}"
{{ $env->is_default ? 'selected' : '' }}>
{{ $env->name }}
</option>
@endforeach
</select>
<!-- 인증 버튼 -->
<button onclick="DevToolsAuth.openModal()" class="px-3 py-2 border border-gray-300 rounded-lg text-sm hover:bg-gray-50 flex items-center gap-2" title="인증 설정">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
<span id="auth-status" class="dev-tools-auth-status text-gray-500">인증 필요</span>
</button>
<!-- 히스토리 버튼 -->
<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>
<!-- API 사용 현황 버튼 -->
<a href="{{ route('dev-tools.api-explorer.usage') }}" class="p-2 text-gray-600 hover:bg-gray-100 rounded-lg" title="API 사용 현황">
<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="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
</a>
<!-- 설정 버튼 -->
<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"
oninput="filterEndpoints()"
autocomplete="off">
<!-- 메서드 필터 -->
<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 cursor-pointer">
<input type="checkbox" value="{{ $method }}" class="hidden method-filter" onchange="toggleMethodFilter(this)">
<span class="method-badge method-{{ strtolower($method) }} opacity-40 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>
<!-- 공유 인증 모달 -->
@include('dev-tools.partials.auth-modal')
<!-- 히스토리 서랍 (오버레이) -->
<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')
{{-- 공유 인증 스크립트 --}}
@include('dev-tools.partials.auth-scripts')
<script>
// 전체 엔드포인트 데이터 (클라이언트 사이드 필터링용)
const allEndpoints = @json($endpoints->flatten(1)->values());
const bookmarkedEndpoints = @json($bookmarks->map(fn($b) => $b->endpoint . '|' . $b->method)->toArray());
let activeMethodFilters = [];
// 현재 선택된 엔드포인트
let currentEndpoint = null;
// 마지막 요청/응답 데이터 (재전송, AI 분석용)
let lastRequestData = null;
let lastResponseData = null;
// 메서드 필터 토글
function toggleMethodFilter(checkbox) {
const badge = checkbox.nextElementSibling;
const method = checkbox.value;
if (checkbox.checked) {
badge.classList.remove('opacity-40');
if (!activeMethodFilters.includes(method)) {
activeMethodFilters.push(method);
}
} else {
badge.classList.add('opacity-40');
activeMethodFilters = activeMethodFilters.filter(m => m !== method);
}
filterEndpoints();
}
// 클라이언트 사이드 필터링
function filterEndpoints() {
const searchQuery = document.getElementById('search-input').value.toLowerCase().trim();
// 필터링
let filtered = allEndpoints.filter(endpoint => {
// 메서드 필터
if (activeMethodFilters.length > 0 && !activeMethodFilters.includes(endpoint.method)) {
return false;
}
// 검색어 필터
if (searchQuery) {
const searchTargets = [
endpoint.path || '',
endpoint.summary || '',
endpoint.description || '',
endpoint.operationId || '',
...(endpoint.tags || [])
].map(s => s.toLowerCase());
return searchTargets.some(target => target.includes(searchQuery));
}
return true;
});
// 태그별 그룹핑
const grouped = {};
filtered.forEach(endpoint => {
const tag = endpoint.tags?.[0] || '기타';
if (!grouped[tag]) grouped[tag] = [];
grouped[tag].push(endpoint);
});
renderSidebar(grouped);
}
// 태그명 한글 매핑
const tagNameMap = {
// 인증/사용자
'Auth': '인증',
'User': '사용자',
'Admin-Users': '관리자-사용자',
'UserInvitation': '사용자 초대',
'UserRole': '사용자 역할',
'Member': '회원',
// 결재
'Approvals': '결재',
'Approval Forms': '결재 양식',
'Approval Lines': '결재선',
// 조직
'Tenant': '테넌트',
'Tenant.Fields': '테넌트 필드',
'Tenant.Option Groups': '테넌트 옵션그룹',
'Tenant.Option Values': '테넌트 옵션값',
'Tenant.Profiles': '테넌트 프로필',
'TenantStatField': '테넌트 통계필드',
'Menu': '메뉴',
'Role': '역할',
'RolePermission': '역할 권한',
'Permission': '권한',
'Department': '부서',
// 품목/제품
'Product': '제품',
'Items': '품목',
'Items BOM': '품목 BOM',
'Items Files': '품목 파일',
'ItemMaster': '품목마스터',
'ItemMaster-Relationships': '품목마스터 관계',
'Category': '카테고리',
'Category-Fields': '카테고리 필드',
'Category-Logs': '카테고리 로그',
'Category-Templates': '카테고리 템플릿',
'Classification': '분류',
// 설계/BOM
'Model': '모델',
'ModelVersion': '모델 버전',
'BomTemplate': 'BOM 템플릿',
'Design BOM': '설계 BOM',
'Design Audit': '설계 감사',
'BOM Calculation': 'BOM 계산',
// 견적/주문
'Estimate': '견적',
'Quote': '견적서',
'Plans': '플랜',
// 거래처
'Client': '거래처',
'ClientGroup': '거래처 그룹',
'Sites': '현장',
// 재무/회계
'Account': '계정',
'BankAccounts': '계좌',
'Cards': '카드',
'Deposits': '입금',
'Withdrawals': '출금',
'Sales': '매출',
'Purchases': '매입',
'Payments': '결제',
'TaxInvoices': '세금계산서',
'BadDebt': '악성채권',
'Pricing': '단가',
'Loans': '대출',
// HR
'Employees': '직원',
'Attendances': '근태',
'Leaves': '휴가',
'Payrolls': '급여',
'WorkSettings': '근무설정',
// 파일/게시판
'Files': '파일',
'Folder': '폴더',
'Board': '게시판',
'Post': '게시글',
'Popup': '팝업',
// 설정
'Settings - Common Codes': '설정 - 공통코드',
'Settings - Fields': '설정 - 필드',
'NotificationSetting': '알림설정',
'BarobillSettings': '바로빌설정',
'Subscriptions': '구독',
// 시스템
'Dashboard': '대시보드',
'Reports': '리포트',
'AI Reports': 'AI 리포트',
'Push': '푸시',
'Internal': '내부',
'API Key 인증': 'API Key 인증',
'default': '기타'
};
// 태그명 한글로 변환 (한글명 (English) 형식)
function getKoreanTagName(tag) {
const koreanName = tagNameMap[tag];
if (koreanName && koreanName !== tag) {
return `${koreanName} (${tag})`;
}
return tag;
}
// 사이드바 렌더링 (즐겨찾기 포함)
function renderSidebar(groupedEndpoints) {
const container = document.getElementById('endpoint-list');
const tags = Object.keys(groupedEndpoints).sort();
let html = '';
// 즐겨찾기 섹션 (메서드 필터 적용)
const bookmarkedItems = allEndpoints.filter(ep => {
const key = ep.path + '|' + ep.method;
if (!bookmarkedEndpoints.includes(key)) return false;
// 메서드 필터 적용
if (activeMethodFilters.length > 0 && !activeMethodFilters.includes(ep.method)) {
return false;
}
// 검색어 필터 적용
const searchQuery = document.getElementById('search-input').value.toLowerCase().trim();
if (searchQuery) {
const searchTargets = [
ep.path || '',
ep.summary || '',
ep.description || '',
ep.operationId || '',
...(ep.tags || [])
].map(s => s.toLowerCase());
return searchTargets.some(target => target.includes(searchQuery));
}
return true;
});
if (bookmarkedItems.length > 0) {
html += `
<div class="border-b border-gray-200 pb-2 mb-2">
<div class="tag-header">
<span class="flex items-center gap-2">
<svg class="w-4 h-4 text-yellow-500" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"/>
</svg>
즐겨찾기 (${bookmarkedItems.length})
</span>
</div>
<div class="space-y-0.5">
`;
bookmarkedItems.forEach(endpoint => {
html += `
<div class="endpoint-item" data-operation-id="${endpoint.operationId}" onclick="selectEndpoint('${endpoint.operationId}', this)">
<span class="method-badge method-${endpoint.method.toLowerCase()}">
${endpoint.method}
</span>
<span class="endpoint-path" title="${escapeHtml(endpoint.summary || endpoint.path)}">
${escapeHtml(endpoint.path)}
</span>
<button onclick="event.stopPropagation(); toggleBookmark('${escapeHtml(endpoint.path)}', '${endpoint.method}', this)"
class="text-yellow-500 hover:text-yellow-600">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"/>
</svg>
</button>
</div>
`;
});
html += `
</div>
</div>
`;
}
// 검색/필터 결과가 없는 경우
if (tags.length === 0 && bookmarkedItems.length === 0) {
container.innerHTML = `
<div class="text-center text-gray-400 py-8">
<svg class="w-8 h-8 mx-auto mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p class="text-sm">검색 결과가 없습니다</p>
</div>
`;
return;
}
// 태그별 그룹
tags.forEach(tag => {
const endpoints = groupedEndpoints[tag];
const tagSlug = tag.toLowerCase().replace(/[^a-z0-9]/g, '-');
const koreanTag = getKoreanTagName(tag);
html += `
<div class="tag-group">
<div class="tag-header" onclick="toggleTagGroup('${tagSlug}')">
<span class="flex items-center gap-2">
<svg class="w-4 h-4 text-gray-400 transition-transform rotate-90" id="chevron-${tagSlug}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
${escapeHtml(koreanTag)}
</span>
<span class="text-xs text-gray-400">${endpoints.length}</span>
</div>
<div id="tag-${tagSlug}" class="space-y-0.5">
`;
endpoints.forEach(endpoint => {
const isBookmarked = bookmarkedEndpoints.includes(endpoint.path + '|' + endpoint.method);
const bookmarkClass = isBookmarked ? 'text-yellow-500' : 'text-gray-400';
html += `
<div class="endpoint-item" data-operation-id="${endpoint.operationId}" onclick="selectEndpoint('${endpoint.operationId}', this)">
<span class="method-badge method-${endpoint.method.toLowerCase()}">
${endpoint.method}
</span>
<span class="endpoint-path" title="${escapeHtml(endpoint.summary || endpoint.path)}">
${escapeHtml(endpoint.path)}
</span>
<button onclick="event.stopPropagation(); toggleBookmark('${escapeHtml(endpoint.path)}', '${endpoint.method}', this)"
class="${bookmarkClass} hover:text-yellow-500">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"/>
</svg>
</button>
</div>
`;
});
html += `
</div>
</div>
`;
});
container.innerHTML = html;
}
// 환경 설정
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,
authToken: option.dataset.authToken
};
}
// 엔드포인트 선택
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'
});
}
// 엔드포인트 선택 (path와 method로 검색) - 즐겨찾기용
function selectEndpointByPath(path, method, element) {
const endpoint = allEndpoints.find(ep => ep.path === path && ep.method === method);
if (endpoint) {
selectEndpoint(endpoint.operationId, element);
} else {
showToast('해당 API를 찾을 수 없습니다.', 'error');
}
}
// API 실행
async function executeApi(event) {
event.preventDefault();
const form = event.target;
const formData = new FormData(form);
const env = getSelectedEnvironment();
// 필수값 밸리데이션
const requiredFields = form.querySelectorAll('[required]');
const missingFields = [];
requiredFields.forEach(field => {
const value = field.value?.trim();
if (!value) {
const label = field.closest('div')?.querySelector('label')?.textContent?.replace('*', '').trim()
|| field.name.replace('path_', '').replace('query_', '');
missingFields.push(label);
field.classList.add('border-red-500', 'bg-red-50');
} else {
field.classList.remove('border-red-500', 'bg-red-50');
}
});
if (missingFields.length > 0) {
showToast(`필수값을 입력해주세요: ${missingFields.join(', ')}`, 'error');
return;
}
// 요청 데이터 구성
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;
}
if (env.authToken) {
headers['Authorization'] = 'Bearer ' + env.authToken;
}
// 바디
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;
// 인증 정보 구성 (공유 인증 모듈 사용)
const authPayload = DevToolsAuth.getAuthPayload();
// 마지막 요청 데이터 저장 (재전송, AI 분석용)
lastRequestData = {
method: method,
url: url,
endpoint: formData.get('endpoint'),
headers: headers,
query: queryParams,
body: body,
environment: env.name,
authPayload: authPayload
};
try {
const response = await fetch('{{ route("dev-tools.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,
...authPayload
})
});
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');
// 마지막 응답 데이터 저장
lastResponseData = result;
// 응답 컨테이너 스타일 변경 (가운데 정렬 → 왼쪽 정렬)
content.className = 'text-left';
// 오류 여부 판단
const isError = result.status >= 400 || result.status === 0;
// 메타 정보
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 = formatJsonBody(result.body);
// 오류 시 액션 버튼
const errorActions = isError ? `
<div class="flex items-center gap-2 p-3 bg-red-50 border border-red-200 rounded-lg mb-4">
<svg class="w-5 h-5 text-red-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
<span class="text-sm text-red-700 flex-1">요청이 실패했습니다 (${result.status || 'Error'})</span>
<button onclick="copyForAiAnalysis()" class="px-3 py-1.5 text-xs bg-purple-600 hover:bg-purple-700 text-white rounded flex items-center gap-1.5 transition">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
AI 분석
</button>
<button onclick="retryLastRequest()" class="px-3 py-1.5 text-xs bg-blue-600 hover:bg-blue-700 text-white rounded flex items-center gap-1.5 transition">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
재전송
</button>
</div>
` : '';
content.innerHTML = `
<div class="space-y-4 text-left">
${errorActions}
<!-- 헤더 -->
<div class="text-left">
<h4 class="text-sm font-medium text-gray-700 mb-2 text-left">응답 헤더</h4>
<div class="bg-gray-50 rounded p-3 text-xs font-mono overflow-x-auto text-left">
${Object.entries(result.headers || {}).map(([k, v]) =>
`<div class="text-left"><span class="text-gray-500">${k}:</span> ${Array.isArray(v) ? v.join(', ') : v}</div>`
).join('')}
</div>
</div>
<!-- 본문 -->
<div class="text-left">
<div class="flex items-center justify-between mb-2">
<h4 class="text-sm font-medium text-gray-700 text-left">응답 본문</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 text-left whitespace-pre-wrap">${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("dev-tools.api-explorer.history.index") }}', {
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("dev-tools.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 loadFromHistory(historyId) {
try {
// 1. 히스토리 데이터 가져오기
const response = await fetch(`/dev-tools/api-explorer/history/${historyId}/replay`, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}'
}
});
if (!response.ok) {
showToast('히스토리를 불러올 수 없습니다.', 'error');
return;
}
const historyData = await response.json();
// 2. 해당 endpoint와 method로 allEndpoints에서 API 찾기
const matchedEndpoint = allEndpoints.find(ep =>
ep.path === historyData.endpoint && ep.method === historyData.method
);
if (!matchedEndpoint) {
// API 스펙에서 찾을 수 없는 경우 (삭제된 API 등)
showToast('해당 API를 찾을 수 없습니다. 스펙이 변경되었을 수 있습니다.', 'error');
toggleHistoryDrawer();
return;
}
// 3. 해당 엔드포인트 패널 로드 (HTMX)
const panelUrl = `/dev-tools/api-explorer/endpoints/${matchedEndpoint.operationId}`;
// 패널 로드 후 데이터 채우기를 위해 pending 데이터 저장
window.pendingHistoryData = historyData;
// HTMX로 패널 로드
htmx.ajax('GET', panelUrl, {
target: '#request-panel',
swap: 'innerHTML'
}).then(() => {
// 패널 로드 완료 후 데이터 채우기
setTimeout(() => {
fillFormFromHistory(window.pendingHistoryData);
window.pendingHistoryData = null;
}, 100); // DOM 렌더링 대기
});
// 4. 사이드바에서 해당 API 활성화
const endpointItems = document.querySelectorAll('.endpoint-item');
endpointItems.forEach(item => {
item.classList.remove('active');
if (item.dataset.operationId === matchedEndpoint.operationId) {
item.classList.add('active');
}
});
// 5. 히스토리 드로어 닫기
toggleHistoryDrawer();
showToast('히스토리가 로드되었습니다.');
} catch (error) {
console.error('히스토리 로드 오류:', error);
showToast('히스토리 로드 중 오류가 발생했습니다.', 'error');
}
}
// 히스토리 데이터로 폼 채우기
function fillFormFromHistory(data) {
if (!data) return;
const form = document.querySelector('#request-panel form');
if (!form) return;
// Path 파라미터 채우기 (endpoint에서 추출)
// 예: /api/v1/users/{id} 에서 {id} 부분
const pathRegex = /\{([^}]+)\}/g;
const endpoint = data.endpoint;
let match;
const pathValues = {};
// endpoint 패턴과 실제 경로를 비교하여 path 파라미터 값 추출
const currentEndpointInput = form.querySelector('[name="endpoint"]');
if (currentEndpointInput) {
const pattern = currentEndpointInput.value;
const patternParts = pattern.split('/');
const endpointParts = endpoint.split('/');
patternParts.forEach((part, index) => {
if (part.startsWith('{') && part.endsWith('}')) {
const paramName = part.slice(1, -1);
if (endpointParts[index]) {
pathValues[paramName] = endpointParts[index];
}
}
});
// Path 파라미터 입력 필드에 값 채우기
Object.entries(pathValues).forEach(([key, value]) => {
const input = form.querySelector(`[name="path_${key}"]`);
if (input) input.value = value;
});
}
// Request Body 채우기
if (data.body) {
const bodyTextarea = form.querySelector('[name="body"]');
if (bodyTextarea) {
const bodyContent = typeof data.body === 'string'
? data.body
: JSON.stringify(data.body, null, 2);
bodyTextarea.value = bodyContent;
}
}
// Query 파라미터는 히스토리에 별도 저장되지 않으므로 생략
// (필요시 endpoint URL에서 파싱하여 채울 수 있음)
}
// 즐겨찾기 토글 (새로고침 없이 로컬 상태 업데이트)
async function toggleBookmark(endpoint, method, button) {
const response = await fetch('{{ route("dev-tools.api-explorer.bookmarks.toggle") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}'
},
body: JSON.stringify({ endpoint, method })
});
const result = await response.json();
const key = endpoint + '|' + method;
if (result.action === 'added') {
// 즐겨찾기 추가
if (!bookmarkedEndpoints.includes(key)) {
bookmarkedEndpoints.push(key);
}
} else {
// 즐겨찾기 제거
const index = bookmarkedEndpoints.indexOf(key);
if (index > -1) {
bookmarkedEndpoints.splice(index, 1);
}
}
// 현재 필터 상태 유지하면서 사이드바 다시 렌더링
filterEndpoints();
}
// 유틸리티
// JSON 본문 포맷팅 (들여쓰기 적용 + \xXX UTF-8 바이트 디코딩)
function formatJsonBody(body) {
if (!body) return '';
try {
// 문자열이면 JSON 파싱 시도
let obj = typeof body === 'string' ? JSON.parse(body) : body;
// JSON.stringify 전에 객체 내 문자열의 \xXX 시퀀스 디코딩
obj = decodeHexEscapes(obj);
// JSON으로 다시 포맷팅 (들여쓰기 포함)
return JSON.stringify(obj, null, 2);
} catch (e) {
// JSON이 아닌 경우 그대로 반환
return typeof body === 'string' ? body : JSON.stringify(body, null, 2);
}
}
// 객체 내 문자열의 \xXX UTF-8 바이트 시퀀스를 재귀적으로 디코딩
function decodeHexEscapes(obj) {
if (typeof obj === 'string') {
// \xXX 패턴을 UTF-8로 디코딩
return obj.replace(/((?:\\x[0-9a-fA-F]{2})+)/g, (match) => {
try {
const hexPairs = match.match(/\\x([0-9a-fA-F]{2})/g);
if (!hexPairs) return match;
const bytes = hexPairs.map(h => parseInt(h.substring(2), 16));
return new TextDecoder('utf-8').decode(new Uint8Array(bytes));
} catch (e) {
return match;
}
});
}
if (Array.isArray(obj)) {
return obj.map(item => decodeHexEscapes(item));
}
if (obj && typeof obj === 'object') {
const result = {};
for (const key in obj) {
result[key] = decodeHexEscapes(obj[key]);
}
return result;
}
return obj;
}
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');
}
}
// 설정 모달 (환경 관리)
function openSettingsModal() {
showToast('환경 설정 기능은 Phase 2에서 구현 예정입니다.', 'info');
}
// ==========================================
// AI 분석 / 재전송 함수
// ==========================================
// AI 분석용 복사
function copyForAiAnalysis() {
if (!lastRequestData || !lastResponseData) {
showToast('요청/응답 데이터가 없습니다.', 'error');
return;
}
const requestBody = lastRequestData.body
? JSON.stringify(lastRequestData.body, null, 2)
: '(없음)';
const responseBody = typeof lastResponseData.body === 'object'
? JSON.stringify(lastResponseData.body, null, 2)
: lastResponseData.body || '(없음)';
const analysisText = `## API 오류 분석 요청
### 요청 정보
- **Method**: ${lastRequestData.method}
- **URL**: ${lastRequestData.url}
- **Environment**: ${lastRequestData.environment}
### 요청 헤더
\`\`\`json
${JSON.stringify(lastRequestData.headers, null, 2)}
\`\`\`
### 요청 본문
\`\`\`json
${requestBody}
\`\`\`
### 응답 정보
- **Status**: ${lastResponseData.status}
- **Duration**: ${lastResponseData.duration_ms}ms
### 응답 헤더
\`\`\`json
${JSON.stringify(lastResponseData.headers, null, 2)}
\`\`\`
### 응답 본문
\`\`\`json
${responseBody}
\`\`\`
---
위 API 요청이 실패했습니다. 원인을 분석하고 해결 방법을 제안해주세요.`;
navigator.clipboard.writeText(analysisText).then(() => {
showToast('AI 분석용 내용이 클립보드에 복사되었습니다.\nClaude나 ChatGPT에 붙여넣기 하세요.', 'success');
}).catch(err => {
console.error('복사 실패:', err);
showToast('복사에 실패했습니다.', 'error');
});
}
// 마지막 요청 재전송
async function retryLastRequest() {
if (!lastRequestData) {
showToast('재전송할 요청 데이터가 없습니다.', 'error');
return;
}
// 응답 패널에 로딩 표시
const content = document.getElementById('response-content');
content.innerHTML = `
<div class="flex items-center justify-center py-12">
<svg class="w-8 h-8 animate-spin text-blue-500" 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>
<span class="ml-3 text-gray-600">재전송 중...</span>
</div>
`;
try {
const response = await fetch('{{ route("dev-tools.api-explorer.execute") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}'
},
body: JSON.stringify({
method: lastRequestData.method,
url: lastRequestData.url,
headers: lastRequestData.headers,
query: lastRequestData.query,
body: lastRequestData.body,
environment: lastRequestData.environment,
...lastRequestData.authPayload
})
});
const result = await response.json();
displayResponse(result);
if (result.status >= 200 && result.status < 400) {
showToast('재전송 성공!', 'success');
}
} catch (error) {
displayResponse({
status: 0,
headers: {},
body: { error: true, message: error.message },
duration_ms: 0
});
}
}
// 토스트 메시지 (타입 지원)
function showToast(message, type = 'success') {
const colors = {
success: 'bg-green-500',
error: 'bg-red-500',
info: 'bg-blue-500',
warning: 'bg-yellow-500'
};
const toast = document.createElement('div');
toast.className = `fixed bottom-4 right-4 ${colors[type] || colors.success} text-white px-4 py-2 rounded-lg shadow-lg z-50 animate-fade-in`;
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => {
toast.remove();
}, 3000);
}
</script>
@endpush