diff --git a/app/Http/Controllers/DevTools/ApiExplorerController.php b/app/Http/Controllers/DevTools/ApiExplorerController.php new file mode 100644 index 00000000..bc31d7f3 --- /dev/null +++ b/app/Http/Controllers/DevTools/ApiExplorerController.php @@ -0,0 +1,387 @@ +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]); + } +} diff --git a/app/Models/DevTools/ApiBookmark.php b/app/Models/DevTools/ApiBookmark.php new file mode 100644 index 00000000..aefe48bb --- /dev/null +++ b/app/Models/DevTools/ApiBookmark.php @@ -0,0 +1,67 @@ + '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', + }; + } +} diff --git a/app/Models/DevTools/ApiEnvironment.php b/app/Models/DevTools/ApiEnvironment.php new file mode 100644 index 00000000..6f20717d --- /dev/null +++ b/app/Models/DevTools/ApiEnvironment.php @@ -0,0 +1,128 @@ + '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); + } +} diff --git a/app/Models/DevTools/ApiHistory.php b/app/Models/DevTools/ApiHistory.php new file mode 100644 index 00000000..e7b27b24 --- /dev/null +++ b/app/Models/DevTools/ApiHistory.php @@ -0,0 +1,107 @@ + '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; + } +} diff --git a/app/Models/DevTools/ApiTemplate.php b/app/Models/DevTools/ApiTemplate.php new file mode 100644 index 00000000..56aafd3f --- /dev/null +++ b/app/Models/DevTools/ApiTemplate.php @@ -0,0 +1,87 @@ + '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', + }; + } +} diff --git a/app/Services/ApiExplorer/ApiExplorerService.php b/app/Services/ApiExplorer/ApiExplorerService.php new file mode 100644 index 00000000..1146bd5b --- /dev/null +++ b/app/Services/ApiExplorer/ApiExplorerService.php @@ -0,0 +1,352 @@ +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, + ]); + } + } +} diff --git a/app/Services/ApiExplorer/ApiRequestService.php b/app/Services/ApiExplorer/ApiRequestService.php new file mode 100644 index 00000000..4d7eb864 --- /dev/null +++ b/app/Services/ApiExplorer/ApiRequestService.php @@ -0,0 +1,192 @@ +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; + } +} diff --git a/app/Services/ApiExplorer/OpenApiParserService.php b/app/Services/ApiExplorer/OpenApiParserService.php new file mode 100644 index 00000000..e4b0a708 --- /dev/null +++ b/app/Services/ApiExplorer/OpenApiParserService.php @@ -0,0 +1,310 @@ +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; + } +} diff --git a/config/api-explorer.php b/config/api-explorer.php new file mode 100644 index 00000000..13628b62 --- /dev/null +++ b/config/api-explorer.php @@ -0,0 +1,97 @@ + 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', + ], +]; diff --git a/database/migrations/2025_12_17_000001_create_api_bookmarks_table.php b/database/migrations/2025_12_17_000001_create_api_bookmarks_table.php new file mode 100644 index 00000000..245f8a40 --- /dev/null +++ b/database/migrations/2025_12_17_000001_create_api_bookmarks_table.php @@ -0,0 +1,36 @@ +id(); + $table->foreignId('user_id')->constrained()->onDelete('cascade')->comment('사용자 ID'); + $table->string('endpoint', 500)->comment('API 엔드포인트'); + $table->string('method', 10)->comment('HTTP 메서드'); + $table->string('display_name', 100)->nullable()->comment('표시명'); + $table->integer('display_order')->default(0)->comment('표시 순서'); + $table->string('color', 20)->nullable()->comment('색상 코드'); + $table->timestamps(); + + $table->unique(['user_id', 'endpoint', 'method'], 'api_bookmarks_unique'); + $table->index('user_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('api_bookmarks'); + } +}; diff --git a/database/migrations/2025_12_17_000002_create_api_templates_table.php b/database/migrations/2025_12_17_000002_create_api_templates_table.php new file mode 100644 index 00000000..513ea418 --- /dev/null +++ b/database/migrations/2025_12_17_000002_create_api_templates_table.php @@ -0,0 +1,40 @@ +id(); + $table->foreignId('user_id')->constrained()->onDelete('cascade')->comment('사용자 ID'); + $table->string('endpoint', 500)->comment('API 엔드포인트'); + $table->string('method', 10)->comment('HTTP 메서드'); + $table->string('name', 100)->comment('템플릿명'); + $table->text('description')->nullable()->comment('설명'); + $table->json('headers')->nullable()->comment('헤더 (JSON)'); + $table->json('path_params')->nullable()->comment('경로 파라미터 (JSON)'); + $table->json('query_params')->nullable()->comment('쿼리 파라미터 (JSON)'); + $table->json('body')->nullable()->comment('요청 본문 (JSON)'); + $table->boolean('is_shared')->default(false)->comment('공유 여부'); + $table->timestamps(); + + $table->index(['user_id', 'endpoint', 'method'], 'api_templates_user_endpoint'); + $table->index('is_shared'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('api_templates'); + } +}; diff --git a/database/migrations/2025_12_17_000003_create_api_histories_table.php b/database/migrations/2025_12_17_000003_create_api_histories_table.php new file mode 100644 index 00000000..561744c1 --- /dev/null +++ b/database/migrations/2025_12_17_000003_create_api_histories_table.php @@ -0,0 +1,40 @@ +id(); + $table->foreignId('user_id')->constrained()->onDelete('cascade')->comment('사용자 ID'); + $table->string('endpoint', 500)->comment('API 엔드포인트'); + $table->string('method', 10)->comment('HTTP 메서드'); + $table->json('request_headers')->nullable()->comment('요청 헤더 (JSON)'); + $table->json('request_body')->nullable()->comment('요청 본문 (JSON)'); + $table->integer('response_status')->comment('응답 상태 코드'); + $table->json('response_headers')->nullable()->comment('응답 헤더 (JSON)'); + $table->longText('response_body')->nullable()->comment('응답 본문'); + $table->integer('duration_ms')->comment('소요 시간 (ms)'); + $table->string('environment', 50)->comment('환경명'); + $table->timestamp('created_at')->useCurrent()->comment('생성일시'); + + $table->index(['user_id', 'created_at'], 'api_histories_user_created'); + $table->index(['endpoint', 'method'], 'api_histories_endpoint_method'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('api_histories'); + } +}; diff --git a/database/migrations/2025_12_17_000004_create_api_environments_table.php b/database/migrations/2025_12_17_000004_create_api_environments_table.php new file mode 100644 index 00000000..8e45b8f0 --- /dev/null +++ b/database/migrations/2025_12_17_000004_create_api_environments_table.php @@ -0,0 +1,36 @@ +id(); + $table->foreignId('user_id')->constrained()->onDelete('cascade')->comment('사용자 ID'); + $table->string('name', 50)->comment('환경명'); + $table->string('base_url', 500)->comment('기본 URL'); + $table->string('api_key', 500)->nullable()->comment('API Key (암호화 저장)'); + $table->text('auth_token')->nullable()->comment('인증 토큰 (암호화 저장)'); + $table->json('variables')->nullable()->comment('환경 변수 (JSON)'); + $table->boolean('is_default')->default(false)->comment('기본 환경 여부'); + $table->timestamps(); + + $table->index('user_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('api_environments'); + } +}; diff --git a/resources/views/dev-tools/api-explorer/index.blade.php b/resources/views/dev-tools/api-explorer/index.blade.php new file mode 100644 index 00000000..83b74dd3 --- /dev/null +++ b/resources/views/dev-tools/api-explorer/index.blade.php @@ -0,0 +1,579 @@ +@extends('layouts.app') + +@section('title', 'API Explorer') + +@push('styles') + +@endpush + +@section('content') + +
OpenAPI 기반 API 테스트 도구
+왼쪽에서 API를 선택하세요
+API를 실행하면 응답이 여기에 표시됩니다
+히스토리가 없습니다
+API를 실행하면 응답이 여기에 표시됩니다
+검색 결과가 없습니다
+