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:
@@ -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 (월) - 메뉴/게시판/사용자 기능 확장
|
||||
|
||||
### 주요 작업
|
||||
|
||||
387
app/Http/Controllers/DevTools/ApiExplorerController.php
Normal file
387
app/Http/Controllers/DevTools/ApiExplorerController.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
69
app/Models/DevTools/ApiBookmark.php
Normal file
69
app/Models/DevTools/ApiBookmark.php
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
130
app/Models/DevTools/ApiEnvironment.php
Normal file
130
app/Models/DevTools/ApiEnvironment.php
Normal 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);
|
||||
}
|
||||
}
|
||||
107
app/Models/DevTools/ApiHistory.php
Normal file
107
app/Models/DevTools/ApiHistory.php
Normal 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;
|
||||
}
|
||||
}
|
||||
89
app/Models/DevTools/ApiTemplate.php
Normal file
89
app/Models/DevTools/ApiTemplate.php
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
352
app/Services/ApiExplorer/ApiExplorerService.php
Normal file
352
app/Services/ApiExplorer/ApiExplorerService.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
192
app/Services/ApiExplorer/ApiRequestService.php
Normal file
192
app/Services/ApiExplorer/ApiRequestService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
310
app/Services/ApiExplorer/OpenApiParserService.php
Normal file
310
app/Services/ApiExplorer/OpenApiParserService.php
Normal 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
97
config/api-explorer.php
Normal 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',
|
||||
],
|
||||
];
|
||||
579
resources/views/dev-tools/api-explorer/index.blade.php
Normal file
579
resources/views/dev-tools/api-explorer/index.blade.php
Normal 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
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user