feat: [api-explorer] Phase 1 기본 구조 및 OpenAPI 파싱 구현

- Config, Service, Controller, View 생성
- Model 4개 (admin_api_* 테이블 참조)
- 3-Panel 레이아웃 (sidebar, request, response)
- HTMX 기반 동적 UI
- 마이그레이션은 api/ 프로젝트에서 관리
This commit is contained in:
2025-12-17 22:06:28 +09:00
parent 6b3d13aced
commit fbd4fb728e
16 changed files with 2805 additions and 5 deletions

View File

@@ -1,5 +1,64 @@
# SAM MNG 작업 현황
## 2025-12-17 (화) - API Explorer Phase 1 개발
### 주요 작업
**API Explorer (Swagger UI 대체 개발 도구)**
- OpenAPI 3.0 JSON 파싱 및 엔드포인트 표시
- 3-Panel 레이아웃 (사이드바, 요청, 응답)
- HTMX 기반 SPA 경험
- 즐겨찾기, 템플릿, 히스토리 관리
- 환경 설정 (API Key 암호화 저장)
### 생성된 파일
**Config**
- `config/api-explorer.php` - 설정 파일
**Migration (4개)**
- `2025_12_17_000001_create_api_bookmarks_table.php`
- `2025_12_17_000002_create_api_templates_table.php`
- `2025_12_17_000003_create_api_histories_table.php`
- `2025_12_17_000004_create_api_environments_table.php`
**Model (4개)**
- `app/Models/DevTools/ApiBookmark.php`
- `app/Models/DevTools/ApiTemplate.php`
- `app/Models/DevTools/ApiHistory.php`
- `app/Models/DevTools/ApiEnvironment.php`
**Service (3개)**
- `app/Services/ApiExplorer/OpenApiParserService.php` - OpenAPI 파싱
- `app/Services/ApiExplorer/ApiRequestService.php` - API 프록시 실행
- `app/Services/ApiExplorer/ApiExplorerService.php` - CRUD 로직
**Controller**
- `app/Http/Controllers/DevTools/ApiExplorerController.php`
**View (5개)**
- `resources/views/dev-tools/api-explorer/index.blade.php`
- `resources/views/dev-tools/api-explorer/partials/sidebar.blade.php`
- `resources/views/dev-tools/api-explorer/partials/request-panel.blade.php`
- `resources/views/dev-tools/api-explorer/partials/response-panel.blade.php`
- `resources/views/dev-tools/api-explorer/partials/history-drawer.blade.php`
**Route**
- `routes/web.php` - 23개 라우트 등록
### 접근 경로
```
/mng/dev-tools/api-explorer
```
### 다음 단계 (Phase 2-5)
- Phase 2: API 실행 기능 완성
- Phase 3: 즐겨찾기/템플릿 UI
- Phase 4: 히스토리/환경 관리
- Phase 5: UX 개선
---
## 2025-12-02 (월) - 메뉴/게시판/사용자 기능 확장
### 주요 작업

View File

@@ -0,0 +1,387 @@
<?php
namespace App\Http\Controllers\DevTools;
use App\Http\Controllers\Controller;
use App\Services\ApiExplorer\ApiExplorerService;
use App\Services\ApiExplorer\ApiRequestService;
use App\Services\ApiExplorer\OpenApiParserService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
/**
* API Explorer Controller
*/
class ApiExplorerController extends Controller
{
public function __construct(
private OpenApiParserService $parser,
private ApiRequestService $requester,
private ApiExplorerService $explorer
) {}
/*
|--------------------------------------------------------------------------
| Main Pages
|--------------------------------------------------------------------------
*/
/**
* 메인 페이지
*/
public function index(): View
{
$userId = auth()->id();
// 기본 환경 초기화
$this->explorer->initializeDefaultEnvironments($userId);
$endpoints = $this->parser->getEndpointsByTag();
$tags = $this->parser->getTags();
$bookmarks = $this->explorer->getBookmarks($userId);
$environments = $this->explorer->getEnvironments($userId);
$defaultEnv = $this->explorer->getDefaultEnvironment($userId);
return view('dev-tools.api-explorer.index', compact(
'endpoints',
'tags',
'bookmarks',
'environments',
'defaultEnv'
));
}
/*
|--------------------------------------------------------------------------
| Endpoint Operations (HTMX partial)
|--------------------------------------------------------------------------
*/
/**
* 엔드포인트 목록 (필터/검색)
*/
public function endpoints(Request $request): View
{
$filters = [
'search' => $request->input('search'),
'methods' => $request->input('methods', []),
'tags' => $request->input('tags', []),
];
$endpoints = $this->parser->filter($filters);
$endpointsByTag = $endpoints->groupBy(fn ($e) => $e['tags'][0] ?? '기타');
$bookmarks = $this->explorer->getBookmarks(auth()->id());
return view('dev-tools.api-explorer.partials.sidebar', compact(
'endpointsByTag',
'bookmarks'
));
}
/**
* 단일 엔드포인트 상세 (요청 패널)
*/
public function endpoint(string $operationId): View
{
$endpoint = $this->parser->getEndpoint($operationId);
if (! $endpoint) {
abort(404, '엔드포인트를 찾을 수 없습니다.');
}
$userId = auth()->id();
$isBookmarked = $this->explorer->isBookmarked($userId, $endpoint['path'], $endpoint['method']);
$templates = $this->explorer->getTemplates($userId, $endpoint['path'], $endpoint['method']);
return view('dev-tools.api-explorer.partials.request-panel', compact(
'endpoint',
'isBookmarked',
'templates'
));
}
/*
|--------------------------------------------------------------------------
| API Execution
|--------------------------------------------------------------------------
*/
/**
* API 실행 (프록시)
*/
public function execute(Request $request): JsonResponse
{
$validated = $request->validate([
'method' => 'required|string|in:GET,POST,PUT,PATCH,DELETE,HEAD,OPTIONS',
'url' => 'required|url',
'headers' => 'nullable|array',
'query' => 'nullable|array',
'body' => 'nullable|array',
'environment' => 'required|string',
]);
// API 실행
$result = $this->requester->execute(
$validated['method'],
$validated['url'],
$validated['headers'] ?? [],
$validated['query'] ?? [],
$validated['body']
);
// 히스토리 저장
$parsedUrl = parse_url($validated['url']);
$endpoint = $parsedUrl['path'] ?? '/';
$this->explorer->logRequest(auth()->id(), [
'endpoint' => $endpoint,
'method' => $validated['method'],
'request_headers' => $this->requester->maskSensitiveHeaders($validated['headers'] ?? []),
'request_body' => $validated['body'],
'response_status' => $result['status'],
'response_headers' => $result['headers'],
'response_body' => is_string($result['body']) ? $result['body'] : json_encode($result['body']),
'duration_ms' => $result['duration_ms'],
'environment' => $validated['environment'],
]);
return response()->json($result);
}
/*
|--------------------------------------------------------------------------
| Bookmarks
|--------------------------------------------------------------------------
*/
/**
* 즐겨찾기 목록
*/
public function bookmarks(): View
{
$bookmarks = $this->explorer->getBookmarks(auth()->id());
return view('dev-tools.api-explorer.partials.bookmarks', compact('bookmarks'));
}
/**
* 즐겨찾기 추가/토글
*/
public function addBookmark(Request $request): JsonResponse
{
$validated = $request->validate([
'endpoint' => 'required|string|max:500',
'method' => 'required|string|max:10',
'display_name' => 'nullable|string|max:100',
]);
$result = $this->explorer->toggleBookmark(auth()->id(), $validated);
return response()->json($result);
}
/**
* 즐겨찾기 제거
*/
public function removeBookmark(int $id): JsonResponse
{
$this->explorer->removeBookmark($id);
return response()->json(['success' => true]);
}
/**
* 즐겨찾기 순서 변경
*/
public function reorderBookmarks(Request $request): JsonResponse
{
$validated = $request->validate([
'order' => 'required|array',
'order.*' => 'integer',
]);
$this->explorer->reorderBookmarks(auth()->id(), $validated['order']);
return response()->json(['success' => true]);
}
/*
|--------------------------------------------------------------------------
| Templates
|--------------------------------------------------------------------------
*/
/**
* 템플릿 목록
*/
public function templates(Request $request): JsonResponse
{
$endpoint = $request->input('endpoint');
$method = $request->input('method');
$templates = $this->explorer->getTemplates(auth()->id(), $endpoint, $method);
return response()->json($templates);
}
/**
* 특정 엔드포인트의 템플릿 목록
*/
public function templatesForEndpoint(string $endpoint): JsonResponse
{
$method = request('method');
$endpoint = urldecode($endpoint);
$templates = $this->explorer->getTemplates(auth()->id(), $endpoint, $method);
return response()->json($templates);
}
/**
* 템플릿 저장
*/
public function saveTemplate(Request $request): JsonResponse
{
$validated = $request->validate([
'endpoint' => 'required|string|max:500',
'method' => 'required|string|max:10',
'name' => 'required|string|max:100',
'description' => 'nullable|string',
'headers' => 'nullable|array',
'path_params' => 'nullable|array',
'query_params' => 'nullable|array',
'body' => 'nullable|array',
'is_shared' => 'nullable|boolean',
]);
$template = $this->explorer->saveTemplate(auth()->id(), $validated);
return response()->json([
'success' => true,
'template' => $template,
]);
}
/**
* 템플릿 삭제
*/
public function deleteTemplate(int $id): JsonResponse
{
$this->explorer->deleteTemplate($id);
return response()->json(['success' => true]);
}
/*
|--------------------------------------------------------------------------
| History
|--------------------------------------------------------------------------
*/
/**
* 히스토리 목록
*/
public function history(Request $request): View
{
$limit = $request->input('limit', 50);
$histories = $this->explorer->getHistory(auth()->id(), $limit);
return view('dev-tools.api-explorer.partials.history-drawer', compact('histories'));
}
/**
* 히스토리 전체 삭제
*/
public function clearHistory(): JsonResponse
{
$count = $this->explorer->clearHistory(auth()->id());
return response()->json([
'success' => true,
'deleted' => $count,
]);
}
/**
* 히스토리 재실행
*/
public function replayHistory(int $id): JsonResponse
{
$history = $this->explorer->getHistoryItem($id);
if (! $history) {
return response()->json(['error' => '히스토리를 찾을 수 없습니다.'], 404);
}
return response()->json([
'endpoint' => $history->endpoint,
'method' => $history->method,
'headers' => $history->request_headers,
'body' => $history->request_body,
]);
}
/*
|--------------------------------------------------------------------------
| Environments
|--------------------------------------------------------------------------
*/
/**
* 환경 목록
*/
public function environments(): JsonResponse
{
$environments = $this->explorer->getEnvironments(auth()->id());
return response()->json($environments);
}
/**
* 환경 저장
*/
public function saveEnvironment(Request $request): JsonResponse
{
$validated = $request->validate([
'id' => 'nullable|integer',
'name' => 'required|string|max:50',
'base_url' => 'required|url|max:500',
'api_key' => 'nullable|string|max:500',
'auth_token' => 'nullable|string',
'variables' => 'nullable|array',
'is_default' => 'nullable|boolean',
]);
if (! empty($validated['id'])) {
$environment = $this->explorer->updateEnvironment($validated['id'], $validated);
} else {
$environment = $this->explorer->saveEnvironment(auth()->id(), $validated);
}
return response()->json([
'success' => true,
'environment' => $environment,
]);
}
/**
* 환경 삭제
*/
public function deleteEnvironment(int $id): JsonResponse
{
$this->explorer->deleteEnvironment($id);
return response()->json(['success' => true]);
}
/**
* 기본 환경 설정
*/
public function setDefaultEnvironment(int $id): JsonResponse
{
$this->explorer->setDefaultEnvironment(auth()->id(), $id);
return response()->json(['success' => true]);
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace App\Models\DevTools;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* API 즐겨찾기 모델
*
* @property int $id
* @property int $user_id
* @property string $endpoint
* @property string $method
* @property string|null $display_name
* @property int $display_order
* @property string|null $color
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
*/
class ApiBookmark extends Model
{
protected $table = 'admin_api_bookmarks';
protected $fillable = [
'user_id',
'endpoint',
'method',
'display_name',
'display_order',
'color',
];
protected $casts = [
'display_order' => 'integer',
];
/**
* 사용자 관계
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* 기본 정렬 스코프
*/
public function scopeOrdered($query)
{
return $query->orderBy('display_order')->orderBy('created_at');
}
/**
* HTTP 메서드 배지 색상
*/
public function getMethodColorAttribute(): string
{
return match (strtoupper($this->method)) {
'GET' => 'green',
'POST' => 'blue',
'PUT' => 'yellow',
'PATCH' => 'orange',
'DELETE' => 'red',
default => 'gray',
};
}
}

View File

@@ -0,0 +1,130 @@
<?php
namespace App\Models\DevTools;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\Crypt;
/**
* API 환경 설정 모델
*
* @property int $id
* @property int $user_id
* @property string $name
* @property string $base_url
* @property string|null $api_key
* @property string|null $auth_token
* @property array|null $variables
* @property bool $is_default
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
*/
class ApiEnvironment extends Model
{
protected $table = 'admin_api_environments';
protected $fillable = [
'user_id',
'name',
'base_url',
'api_key',
'auth_token',
'variables',
'is_default',
];
protected $casts = [
'variables' => 'array',
'is_default' => 'boolean',
];
/**
* 민감 정보 숨김
*/
protected $hidden = [
'api_key',
'auth_token',
];
/**
* 사용자 관계
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* API Key 암호화 저장
*/
public function setApiKeyAttribute(?string $value): void
{
if ($value && config('api-explorer.security.encrypt_tokens', true)) {
$this->attributes['api_key'] = Crypt::encryptString($value);
} else {
$this->attributes['api_key'] = $value;
}
}
/**
* API Key 복호화 조회
*/
public function getDecryptedApiKeyAttribute(): ?string
{
if (! $this->attributes['api_key']) {
return null;
}
if (config('api-explorer.security.encrypt_tokens', true)) {
try {
return Crypt::decryptString($this->attributes['api_key']);
} catch (\Exception $e) {
return $this->attributes['api_key'];
}
}
return $this->attributes['api_key'];
}
/**
* Auth Token 암호화 저장
*/
public function setAuthTokenAttribute(?string $value): void
{
if ($value && config('api-explorer.security.encrypt_tokens', true)) {
$this->attributes['auth_token'] = Crypt::encryptString($value);
} else {
$this->attributes['auth_token'] = $value;
}
}
/**
* Auth Token 복호화 조회
*/
public function getDecryptedAuthTokenAttribute(): ?string
{
if (! $this->attributes['auth_token']) {
return null;
}
if (config('api-explorer.security.encrypt_tokens', true)) {
try {
return Crypt::decryptString($this->attributes['auth_token']);
} catch (\Exception $e) {
return $this->attributes['auth_token'];
}
}
return $this->attributes['auth_token'];
}
/**
* 기본 환경 스코프
*/
public function scopeDefault($query)
{
return $query->where('is_default', true);
}
}

View File

@@ -0,0 +1,107 @@
<?php
namespace App\Models\DevTools;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* API 요청 히스토리 모델
*
* @property int $id
* @property int $user_id
* @property string $endpoint
* @property string $method
* @property array|null $request_headers
* @property array|null $request_body
* @property int $response_status
* @property array|null $response_headers
* @property string|null $response_body
* @property int $duration_ms
* @property string $environment
* @property \Illuminate\Support\Carbon $created_at
*/
class ApiHistory extends Model
{
/**
* updated_at 컬럼 없음
*/
public const UPDATED_AT = null;
protected $table = 'admin_api_histories';
protected $fillable = [
'user_id',
'endpoint',
'method',
'request_headers',
'request_body',
'response_status',
'response_headers',
'response_body',
'duration_ms',
'environment',
];
protected $casts = [
'request_headers' => 'array',
'request_body' => 'array',
'response_headers' => 'array',
'response_status' => 'integer',
'duration_ms' => 'integer',
];
/**
* 사용자 관계
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* 최근순 정렬 스코프
*/
public function scopeLatest($query)
{
return $query->orderByDesc('created_at');
}
/**
* HTTP 메서드 배지 색상
*/
public function getMethodColorAttribute(): string
{
return match (strtoupper($this->method)) {
'GET' => 'green',
'POST' => 'blue',
'PUT' => 'yellow',
'PATCH' => 'orange',
'DELETE' => 'red',
default => 'gray',
};
}
/**
* 응답 상태 코드 색상
*/
public function getStatusColorAttribute(): string
{
return match (true) {
$this->response_status >= 200 && $this->response_status < 300 => 'green',
$this->response_status >= 300 && $this->response_status < 400 => 'blue',
$this->response_status >= 400 && $this->response_status < 500 => 'yellow',
$this->response_status >= 500 => 'red',
default => 'gray',
};
}
/**
* 성공 여부
*/
public function getIsSuccessAttribute(): bool
{
return $this->response_status >= 200 && $this->response_status < 300;
}
}

View File

@@ -0,0 +1,89 @@
<?php
namespace App\Models\DevTools;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* API 요청 템플릿 모델
*
* @property int $id
* @property int $user_id
* @property string $endpoint
* @property string $method
* @property string $name
* @property string|null $description
* @property array|null $headers
* @property array|null $path_params
* @property array|null $query_params
* @property array|null $body
* @property bool $is_shared
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
*/
class ApiTemplate extends Model
{
protected $table = 'admin_api_templates';
protected $fillable = [
'user_id',
'endpoint',
'method',
'name',
'description',
'headers',
'path_params',
'query_params',
'body',
'is_shared',
];
protected $casts = [
'headers' => 'array',
'path_params' => 'array',
'query_params' => 'array',
'body' => 'array',
'is_shared' => 'boolean',
];
/**
* 사용자 관계
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* 공유된 템플릿 스코프
*/
public function scopeShared($query)
{
return $query->where('is_shared', true);
}
/**
* 특정 엔드포인트의 템플릿 스코프
*/
public function scopeForEndpoint($query, string $endpoint, string $method)
{
return $query->where('endpoint', $endpoint)->where('method', $method);
}
/**
* HTTP 메서드 배지 색상
*/
public function getMethodColorAttribute(): string
{
return match (strtoupper($this->method)) {
'GET' => 'green',
'POST' => 'blue',
'PUT' => 'yellow',
'PATCH' => 'orange',
'DELETE' => 'red',
default => 'gray',
};
}
}

View File

@@ -0,0 +1,352 @@
<?php
namespace App\Services\ApiExplorer;
use App\Models\DevTools\ApiBookmark;
use App\Models\DevTools\ApiEnvironment;
use App\Models\DevTools\ApiHistory;
use App\Models\DevTools\ApiTemplate;
use Illuminate\Support\Collection;
/**
* API Explorer 비즈니스 로직 통합 서비스
*/
class ApiExplorerService
{
/*
|--------------------------------------------------------------------------
| Bookmark Operations
|--------------------------------------------------------------------------
*/
/**
* 즐겨찾기 목록 조회
*/
public function getBookmarks(int $userId): Collection
{
return ApiBookmark::where('user_id', $userId)
->ordered()
->get();
}
/**
* 즐겨찾기 추가
*/
public function addBookmark(int $userId, array $data): ApiBookmark
{
$maxOrder = ApiBookmark::where('user_id', $userId)->max('display_order') ?? 0;
return ApiBookmark::create([
'user_id' => $userId,
'endpoint' => $data['endpoint'],
'method' => strtoupper($data['method']),
'display_name' => $data['display_name'] ?? null,
'display_order' => $maxOrder + 1,
'color' => $data['color'] ?? null,
]);
}
/**
* 즐겨찾기 제거
*/
public function removeBookmark(int $bookmarkId): void
{
ApiBookmark::where('id', $bookmarkId)->delete();
}
/**
* 즐겨찾기 순서 변경
*/
public function reorderBookmarks(int $userId, array $order): void
{
foreach ($order as $index => $id) {
ApiBookmark::where('id', $id)
->where('user_id', $userId)
->update(['display_order' => $index]);
}
}
/**
* 즐겨찾기 여부 확인
*/
public function isBookmarked(int $userId, string $endpoint, string $method): bool
{
return ApiBookmark::where('user_id', $userId)
->where('endpoint', $endpoint)
->where('method', strtoupper($method))
->exists();
}
/**
* 즐겨찾기 토글
*/
public function toggleBookmark(int $userId, array $data): array
{
$existing = ApiBookmark::where('user_id', $userId)
->where('endpoint', $data['endpoint'])
->where('method', strtoupper($data['method']))
->first();
if ($existing) {
$existing->delete();
return ['action' => 'removed', 'bookmark' => null];
}
$bookmark = $this->addBookmark($userId, $data);
return ['action' => 'added', 'bookmark' => $bookmark];
}
/*
|--------------------------------------------------------------------------
| Template Operations
|--------------------------------------------------------------------------
*/
/**
* 템플릿 목록 조회
*/
public function getTemplates(int $userId, ?string $endpoint = null, ?string $method = null): Collection
{
$query = ApiTemplate::where(function ($q) use ($userId) {
$q->where('user_id', $userId)
->orWhere('is_shared', true);
});
if ($endpoint) {
$query->where('endpoint', $endpoint);
}
if ($method) {
$query->where('method', strtoupper($method));
}
return $query->orderBy('name')->get();
}
/**
* 템플릿 저장
*/
public function saveTemplate(int $userId, array $data): ApiTemplate
{
return ApiTemplate::create([
'user_id' => $userId,
'endpoint' => $data['endpoint'],
'method' => strtoupper($data['method']),
'name' => $data['name'],
'description' => $data['description'] ?? null,
'headers' => $data['headers'] ?? null,
'path_params' => $data['path_params'] ?? null,
'query_params' => $data['query_params'] ?? null,
'body' => $data['body'] ?? null,
'is_shared' => $data['is_shared'] ?? false,
]);
}
/**
* 템플릿 수정
*/
public function updateTemplate(int $templateId, array $data): ApiTemplate
{
$template = ApiTemplate::findOrFail($templateId);
$template->update([
'name' => $data['name'] ?? $template->name,
'description' => $data['description'] ?? $template->description,
'headers' => $data['headers'] ?? $template->headers,
'path_params' => $data['path_params'] ?? $template->path_params,
'query_params' => $data['query_params'] ?? $template->query_params,
'body' => $data['body'] ?? $template->body,
'is_shared' => $data['is_shared'] ?? $template->is_shared,
]);
return $template->fresh();
}
/**
* 템플릿 삭제
*/
public function deleteTemplate(int $templateId): void
{
ApiTemplate::where('id', $templateId)->delete();
}
/*
|--------------------------------------------------------------------------
| History Operations
|--------------------------------------------------------------------------
*/
/**
* 요청 기록 저장
*/
public function logRequest(int $userId, array $data): ApiHistory
{
// 최대 개수 초과 시 오래된 것 삭제
$maxEntries = config('api-explorer.history.max_entries', 100);
$count = ApiHistory::where('user_id', $userId)->count();
if ($count >= $maxEntries) {
$deleteCount = $count - $maxEntries + 1;
ApiHistory::where('user_id', $userId)
->oldest('created_at')
->limit($deleteCount)
->delete();
}
return ApiHistory::create([
'user_id' => $userId,
'endpoint' => $data['endpoint'],
'method' => strtoupper($data['method']),
'request_headers' => $data['request_headers'] ?? null,
'request_body' => $data['request_body'] ?? null,
'response_status' => $data['response_status'],
'response_headers' => $data['response_headers'] ?? null,
'response_body' => $data['response_body'] ?? null,
'duration_ms' => $data['duration_ms'],
'environment' => $data['environment'],
]);
}
/**
* 히스토리 조회
*/
public function getHistory(int $userId, int $limit = 50): Collection
{
return ApiHistory::where('user_id', $userId)
->latest('created_at')
->limit($limit)
->get();
}
/**
* 히스토리 삭제
*/
public function clearHistory(int $userId): int
{
return ApiHistory::where('user_id', $userId)->delete();
}
/**
* 단일 히스토리 조회
*/
public function getHistoryItem(int $historyId): ?ApiHistory
{
return ApiHistory::find($historyId);
}
/*
|--------------------------------------------------------------------------
| Environment Operations
|--------------------------------------------------------------------------
*/
/**
* 환경 목록 조회
*/
public function getEnvironments(int $userId): Collection
{
return ApiEnvironment::where('user_id', $userId)
->orderBy('is_default', 'desc')
->orderBy('name')
->get();
}
/**
* 환경 저장
*/
public function saveEnvironment(int $userId, array $data): ApiEnvironment
{
// 기본 환경으로 설정 시 기존 기본 해제
if ($data['is_default'] ?? false) {
ApiEnvironment::where('user_id', $userId)
->update(['is_default' => false]);
}
return ApiEnvironment::create([
'user_id' => $userId,
'name' => $data['name'],
'base_url' => $data['base_url'],
'api_key' => $data['api_key'] ?? null,
'auth_token' => $data['auth_token'] ?? null,
'variables' => $data['variables'] ?? null,
'is_default' => $data['is_default'] ?? false,
]);
}
/**
* 환경 수정
*/
public function updateEnvironment(int $environmentId, array $data): ApiEnvironment
{
$environment = ApiEnvironment::findOrFail($environmentId);
// 기본 환경으로 설정 시 기존 기본 해제
if ($data['is_default'] ?? false) {
ApiEnvironment::where('user_id', $environment->user_id)
->where('id', '!=', $environmentId)
->update(['is_default' => false]);
}
$environment->update($data);
return $environment->fresh();
}
/**
* 환경 삭제
*/
public function deleteEnvironment(int $environmentId): void
{
ApiEnvironment::where('id', $environmentId)->delete();
}
/**
* 기본 환경 설정
*/
public function setDefaultEnvironment(int $userId, int $environmentId): void
{
// 기존 기본 해제
ApiEnvironment::where('user_id', $userId)
->update(['is_default' => false]);
// 새 기본 설정
ApiEnvironment::where('id', $environmentId)
->where('user_id', $userId)
->update(['is_default' => true]);
}
/**
* 기본 환경 조회
*/
public function getDefaultEnvironment(int $userId): ?ApiEnvironment
{
return ApiEnvironment::where('user_id', $userId)
->where('is_default', true)
->first();
}
/**
* 기본 환경 초기화 (설정된 환경이 없는 경우)
*/
public function initializeDefaultEnvironments(int $userId): void
{
$count = ApiEnvironment::where('user_id', $userId)->count();
if ($count > 0) {
return;
}
$defaults = config('api-explorer.default_environments', []);
foreach ($defaults as $index => $env) {
$this->saveEnvironment($userId, [
'name' => $env['name'],
'base_url' => $env['base_url'],
'api_key' => $env['api_key'] ?? null,
'is_default' => $index === 0,
]);
}
}
}

View File

@@ -0,0 +1,192 @@
<?php
namespace App\Services\ApiExplorer;
use Illuminate\Support\Facades\Http;
/**
* API 요청 프록시 서비스
*/
class ApiRequestService
{
/**
* API 요청 실행
*
* @return array{status: int, headers: array, body: mixed, duration_ms: int}
*/
public function execute(
string $method,
string $url,
array $headers = [],
array $query = [],
?array $body = null
): array {
// URL 유효성 검사
$this->validateUrl($url);
$startTime = microtime(true);
try {
$request = Http::timeout(config('api-explorer.proxy.timeout', 30))
->withHeaders($headers);
// 쿼리 파라미터 추가
if (! empty($query)) {
$url = $this->appendQueryParams($url, $query);
}
// 요청 실행
$response = match (strtoupper($method)) {
'GET' => $request->get($url),
'POST' => $request->post($url, $body ?? []),
'PUT' => $request->put($url, $body ?? []),
'PATCH' => $request->patch($url, $body ?? []),
'DELETE' => $request->delete($url, $body ?? []),
'HEAD' => $request->head($url),
'OPTIONS' => $request->send('OPTIONS', $url),
default => throw new \InvalidArgumentException("지원하지 않는 HTTP 메서드: {$method}"),
};
$durationMs = (int) ((microtime(true) - $startTime) * 1000);
return [
'status' => $response->status(),
'headers' => $response->headers(),
'body' => $this->parseResponseBody($response),
'duration_ms' => $durationMs,
];
} catch (\Illuminate\Http\Client\ConnectionException $e) {
$durationMs = (int) ((microtime(true) - $startTime) * 1000);
return [
'status' => 0,
'headers' => [],
'body' => [
'error' => true,
'message' => '연결 실패: '.$e->getMessage(),
],
'duration_ms' => $durationMs,
];
} catch (\Exception $e) {
$durationMs = (int) ((microtime(true) - $startTime) * 1000);
return [
'status' => 0,
'headers' => [],
'body' => [
'error' => true,
'message' => '요청 오류: '.$e->getMessage(),
],
'duration_ms' => $durationMs,
];
}
}
/**
* URL 유효성 검사 (화이트리스트)
*/
private function validateUrl(string $url): void
{
$allowedHosts = config('api-explorer.proxy.allowed_hosts', []);
if (empty($allowedHosts)) {
return; // 화이트리스트 미설정 시 모든 호스트 허용
}
$parsedUrl = parse_url($url);
$host = $parsedUrl['host'] ?? '';
if (! in_array($host, $allowedHosts)) {
throw new \InvalidArgumentException("허용되지 않은 호스트: {$host}");
}
}
/**
* 쿼리 파라미터 추가
*/
private function appendQueryParams(string $url, array $query): string
{
// 빈 값 제거
$query = array_filter($query, fn ($v) => $v !== null && $v !== '');
if (empty($query)) {
return $url;
}
$separator = str_contains($url, '?') ? '&' : '?';
return $url.$separator.http_build_query($query);
}
/**
* 응답 본문 파싱
*/
private function parseResponseBody($response): mixed
{
$contentType = $response->header('Content-Type') ?? '';
$body = $response->body();
// JSON 응답
if (str_contains($contentType, 'application/json')) {
$decoded = json_decode($body, true);
if (json_last_error() === JSON_ERROR_NONE) {
return $decoded;
}
}
// 텍스트 응답 (최대 크기 제한)
$maxSize = config('api-explorer.proxy.max_body_size', 1024 * 1024);
if (strlen($body) > $maxSize) {
return [
'truncated' => true,
'message' => '응답이 너무 큽니다. (최대 '.number_format($maxSize / 1024).'KB)',
'size' => strlen($body),
];
}
return $body;
}
/**
* 민감 헤더 마스킹
*/
public function maskSensitiveHeaders(array $headers): array
{
$sensitiveHeaders = config('api-explorer.security.mask_sensitive_headers', []);
$masked = [];
foreach ($headers as $key => $value) {
if (in_array($key, $sensitiveHeaders, true)) {
$masked[$key] = '***MASKED***';
} else {
$masked[$key] = $value;
}
}
return $masked;
}
/**
* 경로 파라미터 치환
*/
public function substitutePathParams(string $path, array $params): string
{
foreach ($params as $key => $value) {
$path = str_replace('{'.$key.'}', $value, $path);
}
return $path;
}
/**
* 변수 치환 ({{VARIABLE}} 패턴)
*/
public function substituteVariables(string $text, array $variables): string
{
foreach ($variables as $key => $value) {
$text = str_replace('{{'.$key.'}}', $value, $text);
}
return $text;
}
}

View File

@@ -0,0 +1,310 @@
<?php
namespace App\Services\ApiExplorer;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\File;
/**
* OpenAPI JSON 파싱 서비스
*/
class OpenApiParserService
{
private ?array $spec = null;
private string $specPath;
public function __construct()
{
$this->specPath = config('api-explorer.openapi_path');
}
/**
* 전체 스펙 파싱
*/
public function parse(): array
{
if ($this->spec !== null) {
return $this->spec;
}
$cacheKey = config('api-explorer.cache.key', 'api_explorer_openapi_spec');
$cacheTtl = config('api-explorer.cache.ttl', 3600);
if (config('api-explorer.cache.enabled', true)) {
$fileModified = File::exists($this->specPath) ? File::lastModified($this->specPath) : 0;
$cacheKeyWithMtime = $cacheKey.'_'.$fileModified;
$this->spec = Cache::remember($cacheKeyWithMtime, $cacheTtl, function () {
return $this->loadSpec();
});
} else {
$this->spec = $this->loadSpec();
}
return $this->spec;
}
/**
* 스펙 파일 로드
*/
private function loadSpec(): array
{
if (! File::exists($this->specPath)) {
return [
'openapi' => '3.0.0',
'info' => ['title' => 'API', 'version' => '1.0.0'],
'paths' => [],
'tags' => [],
];
}
$content = File::get($this->specPath);
$spec = json_decode($content, true);
if (json_last_error() !== JSON_ERROR_NONE) {
return [
'openapi' => '3.0.0',
'info' => ['title' => 'API', 'version' => '1.0.0'],
'paths' => [],
'tags' => [],
'error' => 'JSON 파싱 오류: '.json_last_error_msg(),
];
}
return $spec;
}
/**
* 엔드포인트 목록 추출
*/
public function getEndpoints(): Collection
{
$spec = $this->parse();
$endpoints = collect();
foreach ($spec['paths'] ?? [] as $path => $methods) {
foreach ($methods as $method => $details) {
if (! in_array(strtoupper($method), ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD'])) {
continue;
}
$operationId = $details['operationId'] ?? $this->generateOperationId($method, $path);
$endpoints->push([
'operationId' => $operationId,
'path' => $path,
'method' => strtoupper($method),
'summary' => $details['summary'] ?? '',
'description' => $details['description'] ?? '',
'tags' => $details['tags'] ?? [],
'parameters' => $details['parameters'] ?? [],
'requestBody' => $details['requestBody'] ?? null,
'responses' => $details['responses'] ?? [],
'security' => $details['security'] ?? [],
'deprecated' => $details['deprecated'] ?? false,
]);
}
}
return $endpoints->sortBy([
['tags', 'asc'],
['path', 'asc'],
['method', 'asc'],
])->values();
}
/**
* 단일 엔드포인트 조회
*/
public function getEndpoint(string $operationId): ?array
{
return $this->getEndpoints()->firstWhere('operationId', $operationId);
}
/**
* 태그 목록 추출
*/
public function getTags(): array
{
$spec = $this->parse();
// 정의된 태그
$definedTags = collect($spec['tags'] ?? [])->keyBy('name');
// 실제 사용된 태그
$usedTags = $this->getEndpoints()
->pluck('tags')
->flatten()
->unique()
->filter()
->values();
// 병합
return $usedTags->map(function ($tagName) use ($definedTags) {
$tagInfo = $definedTags->get($tagName, []);
return [
'name' => $tagName,
'description' => $tagInfo['description'] ?? '',
];
})->toArray();
}
/**
* 검색
*/
public function search(string $query): Collection
{
if (empty($query)) {
return $this->getEndpoints();
}
$query = mb_strtolower($query);
return $this->getEndpoints()->filter(function ($endpoint) use ($query) {
// 엔드포인트 경로
if (str_contains(mb_strtolower($endpoint['path']), $query)) {
return true;
}
// summary
if (str_contains(mb_strtolower($endpoint['summary']), $query)) {
return true;
}
// description
if (str_contains(mb_strtolower($endpoint['description']), $query)) {
return true;
}
// operationId
if (str_contains(mb_strtolower($endpoint['operationId']), $query)) {
return true;
}
// 태그
foreach ($endpoint['tags'] as $tag) {
if (str_contains(mb_strtolower($tag), $query)) {
return true;
}
}
// 파라미터명
foreach ($endpoint['parameters'] as $param) {
if (str_contains(mb_strtolower($param['name'] ?? ''), $query)) {
return true;
}
if (str_contains(mb_strtolower($param['description'] ?? ''), $query)) {
return true;
}
}
return false;
})->values();
}
/**
* 필터링
*/
public function filter(array $filters): Collection
{
$endpoints = $this->getEndpoints();
// 메서드 필터
if (! empty($filters['methods'])) {
$methods = array_map('strtoupper', (array) $filters['methods']);
$endpoints = $endpoints->filter(fn ($e) => in_array($e['method'], $methods));
}
// 태그 필터
if (! empty($filters['tags'])) {
$tags = (array) $filters['tags'];
$endpoints = $endpoints->filter(function ($e) use ($tags) {
return count(array_intersect($e['tags'], $tags)) > 0;
});
}
// 검색어
if (! empty($filters['search'])) {
$endpoints = $this->searchInCollection($endpoints, $filters['search']);
}
return $endpoints->values();
}
/**
* 컬렉션 내 검색
*/
private function searchInCollection(Collection $endpoints, string $query): Collection
{
$query = mb_strtolower($query);
return $endpoints->filter(function ($endpoint) use ($query) {
return str_contains(mb_strtolower($endpoint['path']), $query)
|| str_contains(mb_strtolower($endpoint['summary']), $query)
|| str_contains(mb_strtolower($endpoint['description']), $query)
|| str_contains(mb_strtolower($endpoint['operationId']), $query);
});
}
/**
* 태그별 그룹핑
*/
public function getEndpointsByTag(): Collection
{
return $this->getEndpoints()->groupBy(function ($endpoint) {
return $endpoint['tags'][0] ?? '기타';
});
}
/**
* operationId 생성
*/
private function generateOperationId(string $method, string $path): string
{
$cleaned = preg_replace('/[^a-zA-Z0-9]/', '_', $path);
$cleaned = preg_replace('/_+/', '_', $cleaned);
$cleaned = trim($cleaned, '_');
return strtolower($method).'_'.$cleaned;
}
/**
* API 서버 정보
*/
public function getServers(): array
{
$spec = $this->parse();
return $spec['servers'] ?? [];
}
/**
* API 기본 정보
*/
public function getInfo(): array
{
$spec = $this->parse();
return $spec['info'] ?? [];
}
/**
* 캐시 초기화
*/
public function clearCache(): void
{
$cacheKey = config('api-explorer.cache.key', 'api_explorer_openapi_spec');
Cache::forget($cacheKey);
// 파일 수정 시간 포함된 키도 삭제
if (File::exists($this->specPath)) {
$fileModified = File::lastModified($this->specPath);
Cache::forget($cacheKey.'_'.$fileModified);
}
$this->spec = null;
}
}

97
config/api-explorer.php Normal file
View File

@@ -0,0 +1,97 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| OpenAPI Spec File Path
|--------------------------------------------------------------------------
|
| api-docs.json 파일 경로
|
*/
'openapi_path' => env('API_EXPLORER_OPENAPI_PATH', base_path('../api/storage/api-docs/api-docs.json')),
/*
|--------------------------------------------------------------------------
| Default Environments
|--------------------------------------------------------------------------
|
| 기본 환경 설정 (사용자가 커스텀 환경을 추가하기 전 기본값)
|
*/
'default_environments' => [
[
'name' => '로컬',
'base_url' => 'http://api.sam.kr',
'api_key' => env('API_EXPLORER_LOCAL_KEY', ''),
],
[
'name' => '개발',
'base_url' => 'https://api.codebridge-x.com',
'api_key' => env('API_EXPLORER_DEV_KEY', ''),
],
],
/*
|--------------------------------------------------------------------------
| Proxy Settings
|--------------------------------------------------------------------------
|
| API 프록시 설정
|
*/
'proxy' => [
'timeout' => 30, // 초
'max_body_size' => 1024 * 1024, // 1MB
'allowed_hosts' => [ // 화이트리스트
'api.sam.kr',
'api.codebridge-x.com',
'localhost',
'127.0.0.1',
],
],
/*
|--------------------------------------------------------------------------
| History Settings
|--------------------------------------------------------------------------
|
| 히스토리 설정
|
*/
'history' => [
'max_entries' => 100, // 사용자당 최대
'retention_days' => 30, // 보관 기간
],
/*
|--------------------------------------------------------------------------
| Security Settings
|--------------------------------------------------------------------------
|
| 보안 설정
|
*/
'security' => [
'encrypt_tokens' => true, // API Key/Token 암호화
'mask_sensitive_headers' => [ // 히스토리에서 마스킹
'Authorization',
'X-API-KEY',
'Cookie',
],
],
/*
|--------------------------------------------------------------------------
| Cache Settings
|--------------------------------------------------------------------------
|
| OpenAPI 스펙 캐싱 설정
|
*/
'cache' => [
'enabled' => true,
'ttl' => 3600, // 1시간
'key' => 'api_explorer_openapi_spec',
],
];

View File

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

View File

@@ -0,0 +1,51 @@
@if($histories->isEmpty())
<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="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p class="text-sm">히스토리가 없습니다</p>
</div>
@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="flex items-center justify-between mb-1">
<div class="flex items-center gap-2">
<span class="method-badge method-{{ strtolower($history->method) }}">
{{ $history->method }}
</span>
<span class="status-{{ $history->response_status >= 200 && $history->response_status < 300 ? '2xx' : ($history->response_status >= 400 && $history->response_status < 500 ? '4xx' : ($history->response_status >= 500 ? '5xx' : '3xx')) }} font-semibold text-sm">
{{ $history->response_status }}
</span>
</div>
<span class="text-xs text-gray-400">{{ $history->duration_ms }}ms</span>
</div>
<div class="text-sm text-gray-700 truncate" title="{{ $history->endpoint }}">
{{ $history->endpoint }}
</div>
<div class="flex items-center justify-between mt-1">
<span class="text-xs text-gray-400">{{ $history->environment }}</span>
<span class="text-xs text-gray-400">{{ $history->created_at->diffForHumans() }}</span>
</div>
</div>
@endforeach
</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

@@ -0,0 +1,248 @@
<div class="panel-header flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="method-badge method-{{ strtolower($endpoint['method']) }}">
{{ $endpoint['method'] }}
</span>
<h3 class="font-semibold text-gray-700">{{ $endpoint['path'] }}</h3>
</div>
<button onclick="toggleBookmark('{{ $endpoint['path'] }}', '{{ $endpoint['method'] }}', this)"
class="{{ $isBookmarked ? 'text-yellow-500' : 'text-gray-400' }} hover:text-yellow-500 p-1">
<svg class="w-5 h-5" 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>
<div class="panel-content">
<form onsubmit="executeApi(event)" class="space-y-4">
<input type="hidden" name="method" value="{{ $endpoint['method'] }}">
<input type="hidden" name="endpoint" value="{{ $endpoint['path'] }}">
{{-- 설명 --}}
@if($endpoint['summary'] || $endpoint['description'])
<div class="bg-blue-50 border border-blue-200 rounded-lg p-3">
@if($endpoint['summary'])
<p class="font-medium text-blue-800">{{ $endpoint['summary'] }}</p>
@endif
@if($endpoint['description'])
<p class="text-sm text-blue-600 mt-1">{{ $endpoint['description'] }}</p>
@endif
</div>
@endif
{{-- Deprecated 경고 --}}
@if($endpoint['deprecated'] ?? false)
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-3">
<p class="text-yellow-800 font-medium">⚠️ API는 이상 사용되지 않습니다 (Deprecated)</p>
</div>
@endif
{{-- Path Parameters --}}
@php
$pathParams = collect($endpoint['parameters'])->where('in', 'path');
@endphp
@if($pathParams->isNotEmpty())
<div>
<h4 class="text-sm font-semibold text-gray-700 mb-2 flex items-center gap-2">
<svg class="w-4 h-4 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
</svg>
Path Parameters
</h4>
<div class="space-y-2">
@foreach($pathParams as $param)
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">
{{ $param['name'] }}
@if($param['required'] ?? false)
<span class="text-red-500">*</span>
@endif
</label>
<input type="text"
name="path_{{ $param['name'] }}"
placeholder="{{ $param['description'] ?? $param['name'] }}"
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"
{{ ($param['required'] ?? false) ? 'required' : '' }}>
</div>
@endforeach
</div>
</div>
@endif
{{-- Query Parameters --}}
@php
$queryParams = collect($endpoint['parameters'])->where('in', 'query');
@endphp
@if($queryParams->isNotEmpty())
<div>
<h4 class="text-sm font-semibold text-gray-700 mb-2 flex items-center gap-2">
<svg class="w-4 h-4 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Query Parameters
</h4>
<div class="space-y-2">
@foreach($queryParams as $param)
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">
{{ $param['name'] }}
@if($param['required'] ?? false)
<span class="text-red-500">*</span>
@endif
</label>
<input type="text"
name="query_{{ $param['name'] }}"
placeholder="{{ $param['description'] ?? $param['name'] }}"
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"
{{ ($param['required'] ?? false) ? 'required' : '' }}>
</div>
@endforeach
</div>
</div>
@endif
{{-- Request Body --}}
@if(in_array($endpoint['method'], ['POST', 'PUT', 'PATCH']) && ($endpoint['requestBody'] ?? null))
<div>
<h4 class="text-sm font-semibold text-gray-700 mb-2 flex items-center gap-2">
<svg class="w-4 h-4 text-green-500" 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>
Request Body (JSON)
</h4>
@php
// 스키마에서 예제 데이터 추출 시도
$schema = $endpoint['requestBody']['content']['application/json']['schema'] ?? [];
$example = $schema['example'] ?? null;
$properties = $schema['properties'] ?? [];
if (!$example && $properties) {
$example = [];
foreach ($properties as $propName => $propDef) {
$example[$propName] = $propDef['example'] ?? ($propDef['type'] === 'string' ? '' : null);
}
}
@endphp
<textarea name="body"
class="json-editor"
placeholder='{ "key": "value" }'>{{ $example ? json_encode($example, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) : '' }}</textarea>
</div>
@endif
{{-- 템플릿 선택 --}}
@if($templates->isNotEmpty())
<div>
<h4 class="text-sm font-semibold text-gray-700 mb-2">저장된 템플릿</h4>
<select onchange="loadTemplate(this.value)" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm">
<option value="">템플릿 선택...</option>
@foreach($templates as $template)
<option value="{{ $template->id }}">{{ $template->name }}</option>
@endforeach
</select>
</div>
@endif
{{-- 실행 버튼 --}}
<div class="flex items-center gap-2 pt-2">
<button type="submit"
class="flex-1 bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-lg transition flex items-center justify-center gap-2">
<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="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
실행
</button>
<button type="button"
onclick="saveAsTemplate()"
class="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition"
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="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4" />
</svg>
</button>
</div>
</form>
</div>
<script>
// 템플릿 로드
async function loadTemplate(templateId) {
if (!templateId) return;
const response = await fetch(`/dev-tools/api-explorer/templates/${encodeURIComponent('{{ $endpoint['path'] }}')}?method={{ $endpoint['method'] }}`);
const templates = await response.json();
const template = templates.find(t => t.id == templateId);
if (!template) return;
// 폼 필드 채우기
if (template.path_params) {
Object.entries(template.path_params).forEach(([key, value]) => {
const input = document.querySelector(`[name="path_${key}"]`);
if (input) input.value = value;
});
}
if (template.query_params) {
Object.entries(template.query_params).forEach(([key, value]) => {
const input = document.querySelector(`[name="query_${key}"]`);
if (input) input.value = value;
});
}
if (template.body) {
const bodyInput = document.querySelector('[name="body"]');
if (bodyInput) bodyInput.value = JSON.stringify(template.body, null, 2);
}
showToast(`템플릿 "${template.name}"이(가) 로드되었습니다.`);
}
// 템플릿 저장
async function saveAsTemplate() {
const name = prompt('템플릿 이름을 입력하세요:');
if (!name) return;
const form = document.querySelector('form');
const formData = new FormData(form);
// 파라미터 수집
const pathParams = {};
const queryParams = {};
formData.forEach((value, key) => {
if (key.startsWith('path_')) pathParams[key.replace('path_', '')] = value;
if (key.startsWith('query_')) queryParams[key.replace('query_', '')] = value;
});
let body = null;
const bodyText = formData.get('body');
if (bodyText) {
try {
body = JSON.parse(bodyText);
} catch (e) {}
}
const response = await fetch('{{ route("api-explorer.templates.save") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}'
},
body: JSON.stringify({
endpoint: '{{ $endpoint['path'] }}',
method: '{{ $endpoint['method'] }}',
name: name,
path_params: pathParams,
query_params: queryParams,
body: body
})
});
if (response.ok) {
showToast('템플릿이 저장되었습니다.');
} else {
showToast('템플릿 저장에 실패했습니다.', 'error');
}
}
</script>

View File

@@ -0,0 +1,14 @@
{{-- 기본 상태 (응답 ) --}}
<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>

View File

@@ -0,0 +1,75 @@
{{-- 즐겨찾기 섹션 --}}
@if($bookmarks->isNotEmpty())
<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>
즐겨찾기 ({{ $bookmarks->count() }})
</span>
</div>
<div class="space-y-0.5">
@foreach($bookmarks as $bookmark)
<div class="endpoint-item" onclick="selectEndpoint('{{ $bookmark->method }}_{{ str_replace('/', '_', $bookmark->endpoint) }}', this)">
<span class="method-badge method-{{ strtolower($bookmark->method) }}">
{{ $bookmark->method }}
</span>
<span class="endpoint-path" title="{{ $bookmark->endpoint }}">
{{ $bookmark->display_name ?? $bookmark->endpoint }}
</span>
<button onclick="event.stopPropagation(); toggleBookmark('{{ $bookmark->endpoint }}', '{{ $bookmark->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>
@endforeach
</div>
</div>
@endif
{{-- 태그별 엔드포인트 그룹 --}}
@forelse($endpointsByTag as $tag => $endpoints)
<div class="tag-group">
<div class="tag-header" onclick="toggleTagGroup('{{ Str::slug($tag) }}')">
<span class="flex items-center gap-2">
<svg class="w-4 h-4 text-gray-400 transition-transform" id="chevron-{{ Str::slug($tag) }}" 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>
{{ $tag }}
</span>
<span class="text-xs text-gray-400">{{ $endpoints->count() }}</span>
</div>
<div id="tag-{{ Str::slug($tag) }}" class="space-y-0.5">
@foreach($endpoints as $endpoint)
@php
$isBookmarked = $bookmarks->where('endpoint', $endpoint['path'])->where('method', $endpoint['method'])->isNotEmpty();
@endphp
<div class="endpoint-item" onclick="selectEndpoint('{{ $endpoint['operationId'] }}', this)">
<span class="method-badge method-{{ strtolower($endpoint['method']) }}">
{{ $endpoint['method'] }}
</span>
<span class="endpoint-path" title="{{ $endpoint['summary'] ?: $endpoint['path'] }}">
{{ $endpoint['path'] }}
</span>
<button onclick="event.stopPropagation(); toggleBookmark('{{ $endpoint['path'] }}', '{{ $endpoint['method'] }}', this)"
class="{{ $isBookmarked ? 'text-yellow-500' : 'text-gray-400' }} 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>
@endforeach
</div>
</div>
@empty
<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>
@endforelse

View File

@@ -1,11 +1,17 @@
<?php
use App\Http\Controllers\ApiLogController;
use App\Http\Controllers\ArchivedRecordController;
use App\Http\Controllers\Auth\LoginController;
use App\Http\Controllers\BoardController;
use App\Http\Controllers\DailyLogController;
use App\Http\Controllers\DepartmentController;
use App\Http\Controllers\DevTools\ApiExplorerController;
use App\Http\Controllers\DevTools\FlowTesterController;
use App\Http\Controllers\ItemFieldController;
use App\Http\Controllers\Lab\AIController;
use App\Http\Controllers\Lab\ManagementController;
use App\Http\Controllers\Lab\StrategyController;
use App\Http\Controllers\MenuController;
use App\Http\Controllers\PermissionController;
use App\Http\Controllers\PostController;
@@ -15,11 +21,6 @@
use App\Http\Controllers\RoleController;
use App\Http\Controllers\RolePermissionController;
use App\Http\Controllers\TenantController;
use App\Http\Controllers\ItemFieldController;
use App\Http\Controllers\ApiLogController;
use App\Http\Controllers\Lab\AIController;
use App\Http\Controllers\Lab\ManagementController;
use App\Http\Controllers\Lab\StrategyController;
use App\Http\Controllers\UserController;
use Illuminate\Support\Facades\Route;
@@ -294,5 +295,45 @@
Route::post('/{id}/run', [FlowTesterController::class, 'run'])->name('run');
Route::get('/{id}/history', [FlowTesterController::class, 'history'])->name('history');
});
// API Explorer (OpenAPI 3.0 뷰어)
Route::prefix('api-explorer')->name('api-explorer.')->group(function () {
// 메인 뷰
Route::get('/', [ApiExplorerController::class, 'index'])->name('index');
// 엔드포인트 (HTMX)
Route::get('/endpoints', [ApiExplorerController::class, 'endpoints'])->name('endpoints');
Route::get('/endpoints/{operationId}', [ApiExplorerController::class, 'endpoint'])->name('endpoint');
// API 실행
Route::post('/execute', [ApiExplorerController::class, 'execute'])->name('execute');
// 즐겨찾기
Route::get('/bookmarks', [ApiExplorerController::class, 'bookmarks'])->name('bookmarks.index');
Route::post('/bookmarks', [ApiExplorerController::class, 'storeBookmark'])->name('bookmarks.store');
Route::put('/bookmarks/{id}', [ApiExplorerController::class, 'updateBookmark'])->name('bookmarks.update');
Route::delete('/bookmarks/{id}', [ApiExplorerController::class, 'deleteBookmark'])->name('bookmarks.destroy');
Route::post('/bookmarks/toggle', [ApiExplorerController::class, 'toggleBookmark'])->name('bookmarks.toggle');
Route::post('/bookmarks/reorder', [ApiExplorerController::class, 'reorderBookmarks'])->name('bookmarks.reorder');
// 템플릿
Route::get('/templates/{endpoint}', [ApiExplorerController::class, 'templates'])->name('templates.index');
Route::post('/templates', [ApiExplorerController::class, 'storeTemplate'])->name('templates.store');
Route::put('/templates/{id}', [ApiExplorerController::class, 'updateTemplate'])->name('templates.update');
Route::delete('/templates/{id}', [ApiExplorerController::class, 'deleteTemplate'])->name('templates.destroy');
// 히스토리
Route::get('/history', [ApiExplorerController::class, 'history'])->name('history.index');
Route::get('/history/{id}', [ApiExplorerController::class, 'historyDetail'])->name('history.show');
Route::post('/history/{id}/replay', [ApiExplorerController::class, 'replayHistory'])->name('history.replay');
Route::delete('/history/{id}', [ApiExplorerController::class, 'deleteHistory'])->name('history.destroy');
Route::delete('/history', [ApiExplorerController::class, 'clearHistory'])->name('history.clear');
// 환경 설정
Route::get('/environments', [ApiExplorerController::class, 'environments'])->name('environments.index');
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');
});
});
});