feat: [API Explorer] Phase 1 완성 - 히스토리 로드, 밸리데이션, 유니코드 처리

- 히스토리 로드 기능 구현 (loadFromHistory, fillFormFromHistory)
- 클라이언트 사이드 필수값 밸리데이션 추가
- 응답 본문 \xXX UTF-8 바이트 시퀀스 디코딩 (PHP 스택트레이스 한글 깨짐 해결)
- sidebar에 data-operation-id 속성 추가
- history-drawer 함수 연결 수정
- Flow Tester 변수 바인딩 개선
- 마이그레이션 파일 통합 정리
This commit is contained in:
2025-12-18 15:42:01 +09:00
parent 2ed273097e
commit a62337ef5c
15 changed files with 1328 additions and 217 deletions

View File

@@ -43,12 +43,16 @@ public function index(): View
$environments = $this->explorer->getEnvironments($userId);
$defaultEnv = $this->explorer->getDefaultEnvironment($userId);
// 세션에 저장된 토큰
$savedToken = session('api_explorer_token');
return view('dev-tools.api-explorer.index', compact(
'endpoints',
'tags',
'bookmarks',
'environments',
'defaultEnv'
'defaultEnv',
'savedToken'
));
}
@@ -119,13 +123,42 @@ public function execute(Request $request): JsonResponse
'query' => 'nullable|array',
'body' => 'nullable|array',
'environment' => 'required|string',
'token' => 'nullable|string',
'user_id' => 'nullable|integer',
]);
// Bearer 토큰 처리
$token = null;
$headers = $validated['headers'] ?? [];
// 1. 직접 입력된 토큰
if (! empty($validated['token'])) {
$token = $validated['token'];
session(['api_explorer_token' => $token]);
}
// 2. 사용자 선택 시 Sanctum 토큰 발급
elseif (! empty($validated['user_id'])) {
$user = \App\Models\User::find($validated['user_id']);
if ($user) {
$token = $user->createToken('api-explorer', ['*'])->plainTextToken;
session(['api_explorer_token' => $token]);
}
}
// 3. 세션에 저장된 토큰 재사용
elseif (session('api_explorer_token')) {
$token = session('api_explorer_token');
}
// Authorization 헤더 추가 (사용자 입력 토큰이 우선)
if ($token) {
$headers['Authorization'] = 'Bearer ' . $token;
}
// API 실행
$result = $this->requester->execute(
$validated['method'],
$validated['url'],
$validated['headers'] ?? [],
$headers,
$validated['query'] ?? [],
$validated['body']
);
@@ -384,4 +417,26 @@ public function setDefaultEnvironment(int $id): JsonResponse
return response()->json(['success' => true]);
}
/*
|--------------------------------------------------------------------------
| Users (for Authentication)
|--------------------------------------------------------------------------
*/
/**
* 현재 테넌트의 사용자 목록
*/
public function users(): JsonResponse
{
$tenantId = auth()->user()->tenant_id;
$users = \App\Models\User::where('tenant_id', $tenantId)
->select(['id', 'name', 'email'])
->orderBy('name')
->limit(100)
->get();
return response()->json($users);
}
}

View File

@@ -25,7 +25,10 @@ public function index(): View
->orderByDesc('created_at')
->paginate(20);
return view('dev-tools.flow-tester.index', compact('flows'));
// 세션에 저장된 토큰
$savedToken = session('flow_tester_token');
return view('dev-tools.flow-tester.index', compact('flows', 'savedToken'));
}
/**
@@ -329,4 +332,53 @@ public function runDetail(int $runId): View
return view('dev-tools.flow-tester.run-detail', compact('run'));
}
/*
|--------------------------------------------------------------------------
| Token Management
|--------------------------------------------------------------------------
*/
/**
* Bearer 토큰 저장
*/
public function saveToken(Request $request)
{
$validated = $request->validate([
'token' => 'required|string',
]);
session(['flow_tester_token' => $validated['token']]);
return response()->json([
'success' => true,
'message' => '토큰이 저장되었습니다.',
]);
}
/**
* Bearer 토큰 초기화
*/
public function clearToken()
{
session()->forget('flow_tester_token');
return response()->json([
'success' => true,
'message' => '토큰이 초기화되었습니다.',
]);
}
/**
* 현재 토큰 상태 조회
*/
public function tokenStatus()
{
$token = session('flow_tester_token');
return response()->json([
'has_token' => ! empty($token),
'token_preview' => $token ? substr($token, 0, 20).'...' : null,
]);
}
}

View File

@@ -16,6 +16,7 @@
* - {{$timestamp}} - 현재 타임스탬프
* - {{$uuid}} - 랜덤 UUID
* - {{$random:N}} - N자리 랜덤 숫자
* - {{$session.token}} - 세션에 저장된 Bearer 토큰
* - {{$faker.xxx}} - Faker 기반 랜덤 데이터 생성
*/
class VariableBinder
@@ -151,6 +152,12 @@ function ($m) {
// {{$auth.apiKey}} → .env의 API Key
$input = str_replace('{{$auth.apiKey}}', env('FLOW_TESTER_API_KEY', ''), $input);
// {{$session.token}} → 세션에 저장된 Bearer 토큰
if (str_contains($input, '{{$session.token}}')) {
$token = session('flow_tester_token', '');
$input = str_replace('{{$session.token}}', $token, $input);
}
// {{$faker.xxx}} → Faker 기반 랜덤 데이터 생성
$input = $this->resolveFaker($input);

View File

@@ -22,13 +22,13 @@
'default_environments' => [
[
'name' => '로컬',
'base_url' => 'http://api.sam.kr',
'api_key' => env('API_EXPLORER_LOCAL_KEY', ''),
'base_url' => env('API_EXPLORER_LOCAL_URL', 'http://sam-api-1'),
'api_key' => env('FLOW_TESTER_API_KEY', ''),
],
[
'name' => '개발',
'base_url' => 'https://api.codebridge-x.com',
'api_key' => env('API_EXPLORER_DEV_KEY', ''),
'api_key' => env('FLOW_TESTER_API_KEY', ''),
],
],
@@ -46,6 +46,7 @@
'allowed_hosts' => [ // 화이트리스트
'api.sam.kr',
'api.codebridge-x.com',
'sam-api-1', // Docker 컨테이너
'localhost',
'127.0.0.1',
],

View File

@@ -1,36 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('api_bookmarks', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->onDelete('cascade')->comment('사용자 ID');
$table->string('endpoint', 500)->comment('API 엔드포인트');
$table->string('method', 10)->comment('HTTP 메서드');
$table->string('display_name', 100)->nullable()->comment('표시명');
$table->integer('display_order')->default(0)->comment('표시 순서');
$table->string('color', 20)->nullable()->comment('색상 코드');
$table->timestamps();
$table->unique(['user_id', 'endpoint', 'method'], 'api_bookmarks_unique');
$table->index('user_id');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('api_bookmarks');
}
};

View File

@@ -1,40 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('api_templates', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->onDelete('cascade')->comment('사용자 ID');
$table->string('endpoint', 500)->comment('API 엔드포인트');
$table->string('method', 10)->comment('HTTP 메서드');
$table->string('name', 100)->comment('템플릿명');
$table->text('description')->nullable()->comment('설명');
$table->json('headers')->nullable()->comment('헤더 (JSON)');
$table->json('path_params')->nullable()->comment('경로 파라미터 (JSON)');
$table->json('query_params')->nullable()->comment('쿼리 파라미터 (JSON)');
$table->json('body')->nullable()->comment('요청 본문 (JSON)');
$table->boolean('is_shared')->default(false)->comment('공유 여부');
$table->timestamps();
$table->index(['user_id', 'endpoint', 'method'], 'api_templates_user_endpoint');
$table->index('is_shared');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('api_templates');
}
};

View File

@@ -1,40 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('api_histories', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->onDelete('cascade')->comment('사용자 ID');
$table->string('endpoint', 500)->comment('API 엔드포인트');
$table->string('method', 10)->comment('HTTP 메서드');
$table->json('request_headers')->nullable()->comment('요청 헤더 (JSON)');
$table->json('request_body')->nullable()->comment('요청 본문 (JSON)');
$table->integer('response_status')->comment('응답 상태 코드');
$table->json('response_headers')->nullable()->comment('응답 헤더 (JSON)');
$table->longText('response_body')->nullable()->comment('응답 본문');
$table->integer('duration_ms')->comment('소요 시간 (ms)');
$table->string('environment', 50)->comment('환경명');
$table->timestamp('created_at')->useCurrent()->comment('생성일시');
$table->index(['user_id', 'created_at'], 'api_histories_user_created');
$table->index(['endpoint', 'method'], 'api_histories_endpoint_method');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('api_histories');
}
};

View File

@@ -1,36 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('api_environments', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->onDelete('cascade')->comment('사용자 ID');
$table->string('name', 50)->comment('환경명');
$table->string('base_url', 500)->comment('기본 URL');
$table->string('api_key', 500)->nullable()->comment('API Key (암호화 저장)');
$table->text('auth_token')->nullable()->comment('인증 토큰 (암호화 저장)');
$table->json('variables')->nullable()->comment('환경 변수 (JSON)');
$table->boolean('is_default')->default(false)->comment('기본 환경 여부');
$table->timestamps();
$table->index('user_id');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('api_environments');
}
};

265
public/js/fcm.js Normal file
View File

@@ -0,0 +1,265 @@
/**
* FCM (Firebase Cloud Messaging) Push Notification Handler
* Capacitor 앱에서만 동작, 웹 브라우저에서는 무시됨
*
* 필요 조건:
* - localStorage에 'api_access_token' 저장 (API 인증용)
* - window.SAM_CONFIG.apiBaseUrl 설정 (API 서버 주소)
*/
(function() {
'use strict';
// 설정
const CONFIG = {
// API Base URL (Blade에서 주입하거나 기본값 사용)
apiBaseUrl: window.SAM_CONFIG?.apiBaseUrl || 'https://api.codebridge-x.com',
// localStorage 키
fcmTokenKey: 'fcm_token',
apiTokenKey: 'api_access_token',
apiKeyHeader: window.SAM_CONFIG?.apiKey || '',
};
/**
* FCM 초기화 (페이지 로드 시 실행)
*/
document.addEventListener('DOMContentLoaded', async () => {
// Capacitor 환경이 아니면 무시
if (!window.Capacitor?.Plugins?.PushNotifications) {
console.log('[FCM] Not running in Capacitor or PushNotifications not available');
return;
}
await initializeFCM();
});
/**
* FCM 초기화
*/
async function initializeFCM() {
const { PushNotifications } = Capacitor.Plugins;
try {
// 1. 권한 요청
const perm = await PushNotifications.requestPermissions();
console.log('[FCM] Push permission:', perm.receive);
if (perm.receive !== 'granted') {
console.log('[FCM] Push permission not granted');
return;
}
// 2. 기존 리스너 제거 (중복 방지)
PushNotifications.removeAllListeners();
// 3. 토큰 수신 리스너
PushNotifications.addListener('registration', async (token) => {
console.log('[FCM] Token received:', token.value?.substring(0, 20) + '...');
await handleTokenRegistration(token.value);
});
// 4. 등록 에러 핸들링
PushNotifications.addListener('registrationError', (err) => {
console.error('[FCM] Registration error:', err);
});
// 5. 푸시 수신 리스너 (앱이 포그라운드일 때)
PushNotifications.addListener('pushNotificationReceived', (notification) => {
console.log('[FCM] Push received (foreground):', notification);
// Toast 알림 표시 (SweetAlert2 사용)
if (typeof showToast === 'function') {
showToast(notification.body || notification.title, 'info');
}
});
// 6. 푸시 액션 리스너 (알림 클릭 시)
PushNotifications.addListener('pushNotificationActionPerformed', (action) => {
console.log('[FCM] Push action performed:', action);
// 알림에 포함된 URL로 이동
const data = action.notification.data;
if (data && data.url) {
window.location.href = data.url;
}
});
// 7. FCM 등록 시작
await PushNotifications.register();
} catch (error) {
console.error('[FCM] Initialization error:', error);
}
}
/**
* 토큰 등록 처리 (중복 방지 + 변경 감지)
* @param {string} newToken - 새로 받은 FCM 토큰
*/
async function handleTokenRegistration(newToken) {
const oldToken = localStorage.getItem(CONFIG.fcmTokenKey);
// 토큰이 동일하면 재등록 생략
if (oldToken === newToken) {
console.log('[FCM] Token unchanged, skipping registration');
return;
}
// API로 토큰 등록
const success = await registerTokenToServer(newToken);
if (success) {
// 성공 시 localStorage에 저장
localStorage.setItem(CONFIG.fcmTokenKey, newToken);
console.log('[FCM] Token saved to localStorage');
}
}
/**
* FCM 토큰을 서버에 등록
* @param {string} token - FCM 토큰
* @returns {boolean} 성공 여부
*/
async function registerTokenToServer(token) {
const accessToken = localStorage.getItem(CONFIG.apiTokenKey);
if (!accessToken) {
console.warn('[FCM] No API access token found, skipping registration');
return false;
}
try {
const headers = {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Authorization': `Bearer ${accessToken}`,
};
// API Key가 있으면 추가
if (CONFIG.apiKeyHeader) {
headers['X-API-KEY'] = CONFIG.apiKeyHeader;
}
const response = await fetch(`${CONFIG.apiBaseUrl}/api/push/register-token`, {
method: 'POST',
headers: headers,
body: JSON.stringify({
token: token,
platform: getDevicePlatform(),
device_name: getDeviceName(),
app_version: getAppVersion(),
}),
});
if (response.ok) {
const result = await response.json();
console.log('[FCM] Token registered successfully:', result);
return true;
} else {
const error = await response.json().catch(() => ({}));
console.error('[FCM] Token registration failed:', response.status, error);
return false;
}
} catch (error) {
console.error('[FCM] Failed to send token to server:', error);
return false;
}
}
/**
* FCM 토큰 해제 (로그아웃 시 호출)
* @returns {boolean} 성공 여부
*/
async function unregisterToken() {
const token = localStorage.getItem(CONFIG.fcmTokenKey);
const accessToken = localStorage.getItem(CONFIG.apiTokenKey);
if (!token) {
console.log('[FCM] No token to unregister');
return true;
}
if (!accessToken) {
// 토큰은 있지만 API 인증이 없으면 로컬만 삭제
localStorage.removeItem(CONFIG.fcmTokenKey);
console.log('[FCM] Token removed from localStorage (no API auth)');
return true;
}
try {
const headers = {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Authorization': `Bearer ${accessToken}`,
};
if (CONFIG.apiKeyHeader) {
headers['X-API-KEY'] = CONFIG.apiKeyHeader;
}
const response = await fetch(`${CONFIG.apiBaseUrl}/api/push/unregister-token`, {
method: 'POST',
headers: headers,
body: JSON.stringify({
token: token,
}),
});
if (response.ok) {
console.log('[FCM] Token unregistered successfully');
} else {
console.warn('[FCM] Token unregister failed, but continuing logout');
}
// 성공/실패와 관계없이 로컬 토큰 삭제
localStorage.removeItem(CONFIG.fcmTokenKey);
return true;
} catch (error) {
console.error('[FCM] Failed to unregister token:', error);
// 에러가 나도 로컬 토큰은 삭제
localStorage.removeItem(CONFIG.fcmTokenKey);
return false;
}
}
/**
* 디바이스 플랫폼 감지
* @returns {string} 'ios' | 'android' | 'web'
*/
function getDevicePlatform() {
if (window.Capacitor) {
const platform = Capacitor.getPlatform();
if (platform === 'ios') return 'ios';
if (platform === 'android') return 'android';
}
return 'web';
}
/**
* 디바이스명 가져오기
* @returns {string|null}
*/
function getDeviceName() {
if (window.Capacitor?.Plugins?.Device) {
// Capacitor Device 플러그인이 있으면 비동기로 가져와야 함
// 여기서는 간단히 null 반환 (필요시 별도 구현)
return null;
}
return navigator.userAgent?.substring(0, 100) || null;
}
/**
* 앱 버전 가져오기
* @returns {string|null}
*/
function getAppVersion() {
return window.SAM_CONFIG?.appVersion || null;
}
// 전역으로 노출 (로그아웃 시 호출용)
window.FCM = {
unregisterToken: unregisterToken,
reinitialize: initializeFCM,
};
})();

View File

@@ -189,12 +189,23 @@
<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="openAuthModal()" 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="{{ $savedToken ? 'text-green-600' : 'text-gray-500' }}">
{{ $savedToken ? '인증됨' : '인증 필요' }}
</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">
@@ -217,24 +228,20 @@
<!-- 사이드바: API 목록 -->
<div class="api-sidebar">
<div class="api-sidebar-header">
<!-- 검색 -->
<!-- 검색 (클라이언트 사이드 필터링) -->
<input type="text"
id="search-input"
name="search"
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('dev-tools.api-explorer.endpoints') }}"
hx-trigger="input changed delay:300ms"
hx-target="#endpoint-list"
hx-include="#method-filters"
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">
<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">
<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>
@@ -282,6 +289,89 @@ class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-
</div>
</div>
<!-- 인증 모달 -->
<div id="authModal" class="fixed inset-0 z-50 hidden">
<div class="fixed inset-0 bg-black/50" onclick="closeAuthModal()"></div>
<div class="fixed inset-0 flex items-center justify-center p-4">
<div class="bg-white rounded-lg shadow-xl max-w-lg w-full p-6 relative">
<!-- 헤더 -->
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900">인증 설정</h3>
<button onclick="closeAuthModal()" class="text-gray-400 hover:text-gray-600">
<svg class="w-6 h-6" 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 class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">인증 방식</label>
<div class="flex gap-4">
<label class="flex items-center">
<input type="radio" name="authType" value="token" checked onchange="toggleAuthType()" class="mr-2">
<span class="text-sm">토큰 직접 입력</span>
</label>
<label class="flex items-center">
<input type="radio" name="authType" value="user" onchange="toggleAuthType()" class="mr-2">
<span class="text-sm">사용자 선택</span>
</label>
</div>
</div>
<!-- 토큰 직접 입력 섹션 -->
<div id="authTokenSection" class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">Bearer 토큰</label>
<input type="text" id="authBearerToken" placeholder="Bearer 토큰을 입력하세요"
class="w-full border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
value="{{ $savedToken ?? '' }}">
@if($savedToken)
<p class="mt-1 text-xs text-green-600"> 세션에 저장된 토큰이 있습니다.</p>
@endif
</div>
<!-- 사용자 선택 섹션 -->
<div id="authUserSection" class="mb-4 hidden">
<label class="block text-sm font-medium text-gray-700 mb-1">테넌트 사용자 선택</label>
<select id="authSelectedUser" class="w-full border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<option value="">사용자를 선택하세요</option>
</select>
<div id="authUserSpinner" class="hidden mt-2 text-sm text-gray-500">
<svg class="animate-spin h-4 w-4 inline mr-1" 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>
사용자 목록 로딩 ...
</div>
<p class="mt-1 text-xs text-gray-500">선택된 사용자로 Sanctum 토큰이 자동 발급됩니다.</p>
</div>
<!-- 현재 인증 상태 -->
<div id="authCurrentStatus" class="mb-4 p-3 bg-gray-50 rounded-lg">
<div class="text-sm text-gray-600">
<span class="font-medium">현재 상태:</span>
<span id="authStatusDisplay" class="{{ $savedToken ? 'text-green-600' : 'text-gray-500' }}">
{{ $savedToken ? '인증됨' : '인증 필요' }}
</span>
</div>
</div>
<!-- 버튼 -->
<div class="flex justify-end gap-3">
<button type="button" onclick="clearAuth()" class="px-4 py-2 text-red-600 bg-red-50 hover:bg-red-100 rounded-lg transition">
인증 초기화
</button>
<button type="button" onclick="closeAuthModal()" class="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition">
닫기
</button>
<button type="button" onclick="saveAuth()" class="px-4 py-2 text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition">
적용
</button>
</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">
@@ -306,9 +396,141 @@ class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-
@push('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);
}
// 사이드바 렌더링
function renderSidebar(groupedEndpoints) {
const container = document.getElementById('endpoint-list');
const tags = Object.keys(groupedEndpoints).sort();
if (tags.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;
}
let html = '';
tags.forEach(tag => {
const endpoints = groupedEndpoints[tag];
const tagSlug = tag.toLowerCase().replace(/[^a-z0-9]/g, '-');
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(tag)}
</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');
@@ -317,7 +539,8 @@ function getSelectedEnvironment() {
id: select.value,
name: option.text,
baseUrl: option.dataset.baseUrl,
apiKey: option.dataset.apiKey
apiKey: option.dataset.apiKey,
authToken: option.dataset.authToken
};
}
@@ -342,6 +565,26 @@ function selectEndpoint(operationId, element) {
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');
@@ -372,6 +615,9 @@ function selectEndpoint(operationId, element) {
if (env.apiKey) {
headers['X-API-KEY'] = env.apiKey;
}
if (env.authToken) {
headers['Authorization'] = 'Bearer ' + env.authToken;
}
// 바디
let body = null;
@@ -391,6 +637,26 @@ function selectEndpoint(operationId, element) {
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 = {};
if (currentAuthToken) {
authPayload.token = currentAuthToken;
} else if (currentAuthUserId) {
authPayload.user_id = currentAuthUserId;
}
// 마지막 요청 데이터 저장 (재전송, 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',
@@ -404,7 +670,8 @@ function selectEndpoint(operationId, element) {
headers: headers,
query: queryParams,
body: body,
environment: env.name
environment: env.name,
...authPayload
})
});
@@ -429,6 +696,15 @@ 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' :
@@ -441,33 +717,54 @@ function displayResponse(result) {
<span>${result.duration_ms}ms</span>
`;
// 본문
const bodyStr = typeof result.body === 'object'
? JSON.stringify(result.body, null, 2)
: result.body;
// 본문 (유니코드/슬래시 이스케이프 처리)
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">
<div class="space-y-4 text-left">
${errorActions}
<!-- 헤더 -->
<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">
<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><span class="text-gray-500">${k}:</span> ${Array.isArray(v) ? v.join(', ') : v}</div>`
`<div class="text-left"><span class="text-gray-500">${k}:</span> ${Array.isArray(v) ? v.join(', ') : v}</div>`
).join('')}
</div>
</div>
<!-- 본문 -->
<div>
<div class="text-left">
<div class="flex items-center justify-between mb-2">
<h4 class="text-sm font-medium text-gray-700">응답 본문</h4>
<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">${escapeHtml(bodyStr)}</pre>
<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>
`;
@@ -508,6 +805,125 @@ function toggleHistoryDrawer() {
'<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") }}', {
@@ -536,22 +952,55 @@ function toggleHistoryDrawer() {
});
}
// 메서드 필터 토글
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');
});
});
// 유틸리티
// 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;
@@ -583,6 +1032,265 @@ function openSettingsModal() {
showToast('환경 설정 기능은 Phase 2에서 구현 예정입니다.', 'info');
}
// ==========================================
// 인증 관련 함수
// ==========================================
// 인증 상태 변수
let currentAuthToken = '{{ $savedToken ?? '' }}';
let currentAuthUserId = null;
let authUsersLoaded = false;
// 인증 모달 열기
function openAuthModal() {
document.getElementById('authModal').classList.remove('hidden');
// 현재 상태 반영
document.getElementById('authBearerToken').value = currentAuthToken || '';
document.querySelector('input[name="authType"][value="token"]').checked = true;
toggleAuthType();
}
// 인증 모달 닫기
function closeAuthModal() {
document.getElementById('authModal').classList.add('hidden');
}
// 인증 방식 토글
function toggleAuthType() {
const authType = document.querySelector('input[name="authType"]:checked').value;
const tokenSection = document.getElementById('authTokenSection');
const userSection = document.getElementById('authUserSection');
if (authType === 'token') {
tokenSection.classList.remove('hidden');
userSection.classList.add('hidden');
} else {
tokenSection.classList.add('hidden');
userSection.classList.remove('hidden');
loadAuthUsers();
}
}
// 사용자 목록 로드
function loadAuthUsers() {
if (authUsersLoaded) return;
const spinner = document.getElementById('authUserSpinner');
const select = document.getElementById('authSelectedUser');
spinner.classList.remove('hidden');
fetch('{{ route("dev-tools.api-explorer.users") }}', {
headers: {
'Accept': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}'
}
})
.then(response => response.json())
.then(data => {
spinner.classList.add('hidden');
select.innerHTML = '<option value="">사용자를 선택하세요</option>';
const users = data.data || data;
users.forEach(user => {
const option = document.createElement('option');
option.value = user.id;
option.textContent = `${user.name || user.email} (${user.email})`;
select.appendChild(option);
});
authUsersLoaded = true;
})
.catch(err => {
spinner.classList.add('hidden');
console.error('사용자 목록 로드 실패:', err);
showToast('사용자 목록을 불러오는데 실패했습니다.', 'error');
});
}
// 인증 저장 (적용 버튼)
function saveAuth() {
const authType = document.querySelector('input[name="authType"]:checked').value;
if (authType === 'token') {
const token = document.getElementById('authBearerToken').value.trim();
if (token) {
currentAuthToken = token;
currentAuthUserId = null;
updateAuthStatus(true);
showToast('토큰이 적용되었습니다.', 'success');
} else {
showToast('토큰을 입력해주세요.', 'warning');
return;
}
} else {
const userId = document.getElementById('authSelectedUser').value;
if (userId) {
currentAuthUserId = userId;
currentAuthToken = null; // 사용자 선택시 토큰은 서버에서 발급
updateAuthStatus(true);
showToast('사용자가 선택되었습니다. API 실행 시 토큰이 자동 발급됩니다.', 'success');
} else {
showToast('사용자를 선택해주세요.', 'warning');
return;
}
}
closeAuthModal();
}
// 인증 초기화
function clearAuth() {
currentAuthToken = null;
currentAuthUserId = null;
document.getElementById('authBearerToken').value = '';
document.getElementById('authSelectedUser').value = '';
updateAuthStatus(false);
showToast('인증이 초기화되었습니다.', 'info');
}
// 인증 상태 UI 업데이트
function updateAuthStatus(isAuthenticated) {
const statusEl = document.getElementById('auth-status');
const modalStatusEl = document.getElementById('authStatusDisplay');
if (isAuthenticated) {
statusEl.textContent = '인증됨';
statusEl.classList.remove('text-gray-500');
statusEl.classList.add('text-green-600');
if (modalStatusEl) {
modalStatusEl.textContent = '인증됨';
modalStatusEl.classList.remove('text-gray-500');
modalStatusEl.classList.add('text-green-600');
}
} else {
statusEl.textContent = '인증 필요';
statusEl.classList.remove('text-green-600');
statusEl.classList.add('text-gray-500');
if (modalStatusEl) {
modalStatusEl.textContent = '인증 필요';
modalStatusEl.classList.remove('text-green-600');
modalStatusEl.classList.add('text-gray-500');
}
}
}
// ==========================================
// 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 = {

View File

@@ -8,7 +8,7 @@
@else
<div class="divide-y divide-gray-100">
@foreach($histories as $history)
<div class="p-3 hover:bg-gray-50 cursor-pointer" onclick="replayHistory({{ $history->id }})">
<div class="p-3 hover:bg-gray-50 cursor-pointer" onclick="loadFromHistory({{ $history->id }})">
<div class="flex items-center justify-between mb-1">
<div class="flex items-center gap-2">
<span class="method-badge method-{{ strtolower($history->method) }}">
@@ -32,20 +32,3 @@
</div>
@endif
<script>
async function replayHistory(historyId) {
const response = await fetch(`/dev-tools/api-explorer/history/${historyId}/replay`, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}'
}
});
if (response.ok) {
const data = await response.json();
// TODO: 요청 패널에 데이터 채우기
showToast('히스토리가 로드되었습니다.');
toggleHistoryDrawer();
}
}
</script>

View File

@@ -48,7 +48,7 @@ class="text-yellow-500 hover:text-yellow-600">
@php
$isBookmarked = $bookmarks->where('endpoint', $endpoint['path'])->where('method', $endpoint['method'])->isNotEmpty();
@endphp
<div class="endpoint-item" onclick="selectEndpoint('{{ $endpoint['operationId'] }}', this)">
<div class="endpoint-item" data-operation-id="{{ $endpoint['operationId'] }}" onclick="selectEndpoint('{{ $endpoint['operationId'] }}', this)">
<span class="method-badge method-{{ strtolower($endpoint['method']) }}">
{{ $endpoint['method'] }}
</span>

View File

@@ -7,6 +7,15 @@
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold text-gray-800">API 플로우 테스터</h1>
<div class="flex gap-2">
<!-- 인증 버튼 -->
<button onclick="openAuthModal()" class="px-4 py-2 border border-gray-300 rounded-lg 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="{{ $savedToken ? 'text-green-600' : 'text-gray-500' }}">
{{ $savedToken ? '인증됨' : '인증 필요' }}
</span>
</button>
<!-- JSON 작성 가이드 버튼 -->
<button onclick="GuideModal.open()"
class="bg-gray-100 hover:bg-gray-200 text-gray-700 px-4 py-2 rounded-lg transition flex items-center gap-2">
@@ -423,6 +432,59 @@ class="p-2 text-red-600 hover:bg-red-50 rounded-lg transition"
{{-- 모달 포함 --}}
@include('dev-tools.flow-tester.partials.guide-modal')
@include('dev-tools.flow-tester.partials.example-flows')
<!-- 인증 모달 -->
<div id="authModal" class="fixed inset-0 z-50 hidden">
<div class="fixed inset-0 bg-black/50" onclick="closeAuthModal()"></div>
<div class="fixed inset-0 flex items-center justify-center p-4">
<div class="bg-white rounded-lg shadow-xl max-w-lg w-full p-6 relative">
<!-- 헤더 -->
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900">인증 설정</h3>
<button onclick="closeAuthModal()" class="text-gray-400 hover:text-gray-600">
<svg class="w-6 h-6" 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 class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">Bearer 토큰</label>
<input type="text" id="authBearerToken" placeholder="Bearer 토큰을 입력하세요"
class="w-full border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
value="{{ $savedToken ?? '' }}">
@if($savedToken)
<p class="mt-1 text-xs text-green-600"> 세션에 저장된 토큰이 있습니다.</p>
@endif
<p class="mt-1 text-xs text-gray-500">플로우에서 <code class="bg-gray-100 px-1 rounded">\{\{$session.token\}\}</code> 참조할 있습니다.</p>
</div>
<!-- 현재 인증 상태 -->
<div id="authCurrentStatus" class="mb-4 p-3 bg-gray-50 rounded-lg">
<div class="text-sm text-gray-600">
<span class="font-medium">현재 상태:</span>
<span id="authStatusDisplay" class="{{ $savedToken ? 'text-green-600' : 'text-gray-500' }}">
{{ $savedToken ? '인증됨' : '인증 필요' }}
</span>
</div>
</div>
<!-- 버튼 -->
<div class="flex justify-end gap-3">
<button type="button" onclick="clearAuth()" class="px-4 py-2 text-red-600 bg-red-50 hover:bg-red-100 rounded-lg transition">
인증 초기화
</button>
<button type="button" onclick="closeAuthModal()" class="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition">
닫기
</button>
<button type="button" onclick="saveAuth()" class="px-4 py-2 text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition">
저장
</button>
</div>
</div>
</div>
</div>
@endsection
@push('scripts')
@@ -658,5 +720,109 @@ function confirmDelete(id, name) {
});
});
}
/*
|--------------------------------------------------------------------------
| 인증 관리
|--------------------------------------------------------------------------
*/
// 현재 토큰 상태
let currentAuthToken = @json($savedToken ?? '');
function openAuthModal() {
document.getElementById('authModal').classList.remove('hidden');
document.getElementById('authBearerToken').value = currentAuthToken || '';
}
function closeAuthModal() {
document.getElementById('authModal').classList.add('hidden');
}
function saveAuth() {
const token = document.getElementById('authBearerToken').value.trim();
if (!token) {
showToast('토큰을 입력해주세요.', 'warning');
return;
}
// 서버에 토큰 저장
fetch('{{ route("dev-tools.flow-tester.token.save") }}', {
method: 'POST',
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({ token: token }),
})
.then(response => response.json())
.then(data => {
if (data.success) {
currentAuthToken = token;
updateAuthStatus(true);
showToast('토큰이 저장되었습니다.', 'success');
closeAuthModal();
} else {
showToast(data.message || '저장 실패', 'error');
}
})
.catch(error => {
showToast('오류 발생: ' + error.message, 'error');
});
}
function clearAuth() {
// 서버에서 토큰 삭제
fetch('{{ route("dev-tools.flow-tester.token.clear") }}', {
method: 'POST',
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'Accept': 'application/json',
},
})
.then(response => response.json())
.then(data => {
if (data.success) {
currentAuthToken = '';
document.getElementById('authBearerToken').value = '';
updateAuthStatus(false);
showToast('인증이 초기화되었습니다.', 'info');
} else {
showToast(data.message || '초기화 실패', 'error');
}
})
.catch(error => {
showToast('오류 발생: ' + error.message, 'error');
});
}
function updateAuthStatus(isAuthenticated) {
const statusEl = document.getElementById('auth-status');
const modalStatusEl = document.getElementById('authStatusDisplay');
if (isAuthenticated) {
statusEl.textContent = '인증됨';
statusEl.classList.remove('text-gray-500');
statusEl.classList.add('text-green-600');
if (modalStatusEl) {
modalStatusEl.textContent = '인증됨';
modalStatusEl.classList.remove('text-gray-500');
modalStatusEl.classList.add('text-green-600');
}
} else {
statusEl.textContent = '인증 필요';
statusEl.classList.remove('text-green-600');
statusEl.classList.add('text-gray-500');
if (modalStatusEl) {
modalStatusEl.textContent = '인증 필요';
modalStatusEl.classList.remove('text-green-600');
modalStatusEl.classList.add('text-gray-500');
}
}
}
</script>
@endpush

View File

@@ -93,9 +93,9 @@ class="flex items-center gap-2 px-3 py-2 text-sm font-medium text-gray-700 hover
<div class="border-t border-gray-200 my-1"></div>
<!-- Logout -->
<form method="POST" action="{{ route('logout') }}">
<form method="POST" action="{{ route('logout') }}" id="logout-form">
@csrf
<button type="submit" class="block w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-gray-100">
<button type="button" onclick="handleLogout()" class="block w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-gray-100">
로그아웃
</button>
</form>
@@ -114,5 +114,23 @@ class="flex items-center gap-2 px-3 py-2 text-sm font-medium text-gray-700 hover
userMenu.classList.add('hidden');
}
});
/**
* 로그아웃 처리 (FCM 토큰 해제 후 로그아웃)
*/
async function handleLogout() {
// FCM 토큰 해제 시도 (window.FCM이 있는 경우에만)
if (window.FCM && typeof window.FCM.unregisterToken === 'function') {
try {
await window.FCM.unregisterToken();
console.log('[Logout] FCM token unregistered');
} catch (error) {
console.warn('[Logout] FCM unregister failed:', error);
}
}
// 로그아웃 폼 제출
document.getElementById('logout-form').submit();
}
</script>
@endpush

View File

@@ -283,6 +283,11 @@
Route::post('/', [FlowTesterController::class, 'store'])->name('store');
Route::post('/validate-json', [FlowTesterController::class, 'validateJson'])->name('validate-json');
// 토큰 관리 라우트
Route::post('/token/save', [FlowTesterController::class, 'saveToken'])->name('token.save');
Route::post('/token/clear', [FlowTesterController::class, 'clearToken'])->name('token.clear');
Route::get('/token/status', [FlowTesterController::class, 'tokenStatus'])->name('token.status');
// /runs/* 관련 라우트 (고정 경로)
Route::get('/runs/{runId}/status', [FlowTesterController::class, 'runStatus'])->name('run-status');
Route::get('/runs/{runId}', [FlowTesterController::class, 'runDetail'])->name('run-detail');
@@ -334,6 +339,9 @@
Route::post('/environments', [ApiExplorerController::class, 'storeEnvironment'])->name('environments.store');
Route::put('/environments/{id}', [ApiExplorerController::class, 'updateEnvironment'])->name('environments.update');
Route::delete('/environments/{id}', [ApiExplorerController::class, 'deleteEnvironment'])->name('environments.destroy');
// 사용자 목록 (인증용)
Route::get('/users', [ApiExplorerController::class, 'users'])->name('users');
});
});
});