- 품목관리 3-Panel 레이아웃 (좌:목록, 중:BOM/수식산출, 우:상세) - FormulaApiService로 API 견적수식 엔진 연동 - FG 품목 선택 시 기본값(W:1000, H:1000, QTY:1) 자동 산출 - 수식 산출 결과 트리 렌더링 (그룹별/소계/합계) - 중앙 패널 클릭 시 우측 상세만 변경 (skipCenterUpdate) - API 인증 버튼 전역 헤더로 이동 (모든 페이지에서 사용 가능) - FormulaApiService에 Bearer 토큰 지원 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1286 lines
49 KiB
PHP
1286 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>
|
|
|
|
{{-- 인증 모달: 전역 레이아웃(app.blade.php)에서 포함 --}}
|
|
|
|
<!-- 히스토리 서랍 (오버레이) -->
|
|
<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')
|
|
{{-- 인증 스크립트: 전역 레이아웃(app.blade.php)에서 포함 --}}
|
|
|
|
<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
|