diff --git a/app/Http/Controllers/DevTools/ApiExplorerController.php b/app/Http/Controllers/DevTools/ApiExplorerController.php index 50a2cf8f..f8dc2c45 100644 --- a/app/Http/Controllers/DevTools/ApiExplorerController.php +++ b/app/Http/Controllers/DevTools/ApiExplorerController.php @@ -5,6 +5,7 @@ use App\Http\Controllers\Controller; use App\Services\ApiExplorer\ApiExplorerService; use App\Services\ApiExplorer\ApiRequestService; +use App\Services\ApiExplorer\ApiUsageService; use App\Services\ApiExplorer\OpenApiParserService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -18,7 +19,8 @@ class ApiExplorerController extends Controller public function __construct( private OpenApiParserService $parser, private ApiRequestService $requester, - private ApiExplorerService $explorer + private ApiExplorerService $explorer, + private ApiUsageService $usageService ) {} /* @@ -199,9 +201,28 @@ public function bookmarks(): View } /** - * 즐겨찾기 추가/토글 + * 즐겨찾기 저장 */ - public function addBookmark(Request $request): JsonResponse + public function storeBookmark(Request $request): JsonResponse + { + $validated = $request->validate([ + 'endpoint' => 'required|string|max:500', + 'method' => 'required|string|max:10', + 'display_name' => 'nullable|string|max:100', + ]); + + $bookmark = $this->explorer->addBookmark(auth()->id(), $validated); + + return response()->json([ + 'success' => true, + 'bookmark' => $bookmark, + ]); + } + + /** + * 즐겨찾기 토글 + */ + public function toggleBookmark(Request $request): JsonResponse { $validated = $request->validate([ 'endpoint' => 'required|string|max:500', @@ -439,4 +460,182 @@ public function users(): JsonResponse return response()->json($users); } + + /** + * 즐겨찾기 수정 + */ + public function updateBookmark(Request $request, int $id): JsonResponse + { + $validated = $request->validate([ + 'display_name' => 'nullable|string|max:100', + ]); + + $bookmark = $this->explorer->updateBookmark($id, $validated); + + return response()->json([ + 'success' => true, + 'bookmark' => $bookmark, + ]); + } + + /** + * 즐겨찾기 삭제 + */ + public function deleteBookmark(int $id): JsonResponse + { + $this->explorer->removeBookmark($id); + + return response()->json(['success' => true]); + } + + /* + |-------------------------------------------------------------------------- + | API Usage & Deprecation + |-------------------------------------------------------------------------- + */ + + /** + * API 사용 현황 페이지 + */ + public function usage(): View + { + $comparison = $this->usageService->getApiUsageComparison(); + $deprecations = $this->usageService->getDeprecations(); + $dailyTrend = $this->usageService->getDailyTrend(30); + + return view('dev-tools.api-explorer.usage', compact( + 'comparison', + 'deprecations', + 'dailyTrend' + )); + } + + /** + * API 사용 통계 조회 (JSON) + */ + public function usageStats(): JsonResponse + { + $comparison = $this->usageService->getApiUsageComparison(); + + return response()->json($comparison); + } + + /** + * 폐기 후보 목록 조회 + */ + public function deprecations(Request $request): JsonResponse + { + $status = $request->input('status'); + $deprecations = $this->usageService->getDeprecations($status); + + return response()->json($deprecations); + } + + /** + * 폐기 후보 추가 + */ + public function addDeprecation(Request $request): JsonResponse + { + $validated = $request->validate([ + 'endpoint' => 'required|string|max:500', + 'method' => 'required|string|max:10', + 'reason' => 'nullable|string', + ]); + + $deprecation = $this->usageService->addDeprecationCandidate( + $validated['endpoint'], + $validated['method'], + $validated['reason'] ?? null, + auth()->id() + ); + + return response()->json([ + 'success' => true, + 'deprecation' => $deprecation, + ]); + } + + /** + * 미사용 API 전체를 폐기 후보로 추가 + */ + public function addAllUnusedAsDeprecation(): JsonResponse + { + $count = $this->usageService->addAllUnusedAsCandidate(auth()->id()); + + return response()->json([ + 'success' => true, + 'added_count' => $count, + ]); + } + + /** + * 폐기 상태 변경 + */ + public function updateDeprecation(Request $request, int $id): JsonResponse + { + $validated = $request->validate([ + 'status' => 'required|string|in:candidate,scheduled,deprecated,removed', + 'reason' => 'nullable|string', + 'scheduled_date' => 'nullable|date', + ]); + + $scheduledDate = ! empty($validated['scheduled_date']) + ? new \DateTime($validated['scheduled_date']) + : null; + + $deprecation = $this->usageService->updateDeprecationStatus( + $id, + $validated['status'], + $validated['reason'] ?? null, + $scheduledDate + ); + + return response()->json([ + 'success' => true, + 'deprecation' => $deprecation, + ]); + } + + /** + * 폐기 후보 삭제 + */ + public function removeDeprecation(int $id): JsonResponse + { + $this->usageService->removeDeprecation($id); + + return response()->json(['success' => true]); + } + + /** + * 일별 API 호출 추이 + */ + public function dailyTrend(Request $request): JsonResponse + { + $days = $request->input('days', 30); + $trend = $this->usageService->getDailyTrend($days); + + return response()->json($trend); + } + + /** + * 인기 API 목록 + */ + public function popularApis(Request $request): JsonResponse + { + $limit = $request->input('limit', 20); + $popular = $this->usageService->getPopularApis($limit); + + return response()->json($popular); + } + + /** + * 오래된 API 목록 (N일 이상 미사용) + */ + public function staleApis(Request $request): JsonResponse + { + $days = $request->input('days', 30); + $stale = $this->usageService->getStaleApis($days); + + return response()->json($stale); + } } diff --git a/app/Models/DevTools/ApiDeprecation.php b/app/Models/DevTools/ApiDeprecation.php new file mode 100644 index 00000000..9cb9d10a --- /dev/null +++ b/app/Models/DevTools/ApiDeprecation.php @@ -0,0 +1,112 @@ + 'date', + 'deprecated_date' => 'date', + ]; + + /** + * 상태 상수 + */ + public const STATUS_CANDIDATE = 'candidate'; // 삭제 후보 + public const STATUS_SCHEDULED = 'scheduled'; // 삭제 예정 + public const STATUS_DEPRECATED = 'deprecated'; // 폐기됨 (사용 중단) + public const STATUS_REMOVED = 'removed'; // 완전 삭제 + + /** + * 상태 라벨 + */ + public static function statusLabels(): array + { + return [ + self::STATUS_CANDIDATE => '삭제 후보', + self::STATUS_SCHEDULED => '삭제 예정', + self::STATUS_DEPRECATED => '폐기됨', + self::STATUS_REMOVED => '삭제됨', + ]; + } + + /** + * 상태 라벨 접근자 + */ + public function getStatusLabelAttribute(): string + { + return self::statusLabels()[$this->status] ?? $this->status; + } + + /** + * 상태 색상 접근자 + */ + public function getStatusColorAttribute(): string + { + return match ($this->status) { + self::STATUS_CANDIDATE => 'yellow', + self::STATUS_SCHEDULED => 'orange', + self::STATUS_DEPRECATED => 'red', + self::STATUS_REMOVED => 'gray', + default => 'gray', + }; + } + + /** + * 생성자 관계 + */ + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + /** + * 후보 상태 스코프 + */ + public function scopeCandidates($query) + { + return $query->where('status', self::STATUS_CANDIDATE); + } + + /** + * 활성 상태 스코프 (삭제되지 않은 것들) + */ + public function scopeActive($query) + { + return $query->whereIn('status', [ + self::STATUS_CANDIDATE, + self::STATUS_SCHEDULED, + self::STATUS_DEPRECATED, + ]); + } +} \ No newline at end of file diff --git a/app/Services/ApiExplorer/ApiExplorerService.php b/app/Services/ApiExplorer/ApiExplorerService.php index 1146bd5b..e91312c5 100644 --- a/app/Services/ApiExplorer/ApiExplorerService.php +++ b/app/Services/ApiExplorer/ApiExplorerService.php @@ -54,6 +54,26 @@ public function removeBookmark(int $bookmarkId): void ApiBookmark::where('id', $bookmarkId)->delete(); } + /** + * 즐겨찾기 수정 + */ + public function updateBookmark(int $bookmarkId, array $data): ?ApiBookmark + { + $bookmark = ApiBookmark::find($bookmarkId); + + if (! $bookmark) { + return null; + } + + if (isset($data['display_name'])) { + $bookmark->display_name = $data['display_name']; + } + + $bookmark->save(); + + return $bookmark; + } + /** * 즐겨찾기 순서 변경 */ diff --git a/app/Services/ApiExplorer/ApiUsageService.php b/app/Services/ApiExplorer/ApiUsageService.php new file mode 100644 index 00000000..7d840db3 --- /dev/null +++ b/app/Services/ApiExplorer/ApiUsageService.php @@ -0,0 +1,257 @@ +selectRaw('COUNT(*) as call_count') + ->selectRaw('MAX(created_at) as last_called_at') + ->selectRaw('AVG(duration_ms) as avg_duration_ms') + ->selectRaw('SUM(CASE WHEN response_status >= 200 AND response_status < 300 THEN 1 ELSE 0 END) as success_count') + ->selectRaw('SUM(CASE WHEN response_status >= 400 THEN 1 ELSE 0 END) as error_count') + ->groupBy('endpoint', 'method') + ->orderByDesc('call_count') + ->get(); + } + + /** + * 전체 API 목록과 사용 현황 비교 + * + * @return array ['used' => [...], 'unused' => [...], 'stats' => [...]] + */ + public function getApiUsageComparison(): array + { + // Swagger에서 전체 API 목록 조회 + $allEndpoints = $this->parser->getEndpoints(); + + // 사용된 API 통계 + $usageStats = $this->getUsageStats()->keyBy(function ($item) { + return $item->endpoint . '|' . $item->method; + }); + + // 폐기 후보 목록 + $deprecations = ApiDeprecation::active() + ->get() + ->keyBy(function ($item) { + return $item->endpoint . '|' . $item->method; + }); + + $used = []; + $unused = []; + + foreach ($allEndpoints as $endpoint) { + $key = $endpoint['path'] . '|' . $endpoint['method']; + $stats = $usageStats->get($key); + $deprecation = $deprecations->get($key); + + $item = [ + 'endpoint' => $endpoint['path'], + 'method' => $endpoint['method'], + 'summary' => $endpoint['summary'] ?? '', + 'tags' => $endpoint['tags'] ?? [], + 'call_count' => $stats->call_count ?? 0, + 'last_called_at' => $stats->last_called_at ?? null, + 'avg_duration_ms' => $stats->avg_duration_ms ?? null, + 'success_count' => $stats->success_count ?? 0, + 'error_count' => $stats->error_count ?? 0, + 'deprecation' => $deprecation, + ]; + + if ($stats) { + $used[] = $item; + } else { + $unused[] = $item; + } + } + + // 사용된 API는 호출 횟수 순으로 정렬 + usort($used, fn ($a, $b) => $b['call_count'] <=> $a['call_count']); + + return [ + 'used' => $used, + 'unused' => $unused, + 'summary' => [ + 'total' => count($allEndpoints), + 'used_count' => count($used), + 'unused_count' => count($unused), + 'deprecation_count' => $deprecations->count(), + ], + ]; + } + + /** + * 미사용 API 목록 조회 + */ + public function getUnusedApis(): Collection + { + $comparison = $this->getApiUsageComparison(); + + return collect($comparison['unused']); + } + + /** + * 폐기 후보 추가 + */ + public function addDeprecationCandidate( + string $endpoint, + string $method, + ?string $reason = null, + ?int $userId = null + ): ApiDeprecation { + return ApiDeprecation::updateOrCreate( + ['endpoint' => $endpoint, 'method' => strtoupper($method)], + [ + 'status' => ApiDeprecation::STATUS_CANDIDATE, + 'reason' => $reason, + 'created_by' => $userId, + ] + ); + } + + /** + * 폐기 후보 일괄 추가 (미사용 API 전체) + */ + public function addAllUnusedAsCandidate(?int $userId = null): int + { + $unused = $this->getUnusedApis(); + $count = 0; + + foreach ($unused as $api) { + // 이미 등록되지 않은 것만 추가 + $exists = ApiDeprecation::where('endpoint', $api['endpoint']) + ->where('method', $api['method']) + ->exists(); + + if (! $exists) { + $this->addDeprecationCandidate( + $api['endpoint'], + $api['method'], + '미사용 API (자동 등록)', + $userId + ); + $count++; + } + } + + return $count; + } + + /** + * 폐기 상태 변경 + */ + public function updateDeprecationStatus( + int $id, + string $status, + ?string $reason = null, + ?\DateTime $scheduledDate = null + ): ApiDeprecation { + $deprecation = ApiDeprecation::findOrFail($id); + + $data = ['status' => $status]; + + if ($reason !== null) { + $data['reason'] = $reason; + } + + if ($scheduledDate !== null) { + $data['scheduled_date'] = $scheduledDate; + } + + if ($status === ApiDeprecation::STATUS_DEPRECATED) { + $data['deprecated_date'] = now(); + } + + $deprecation->update($data); + + return $deprecation->fresh(); + } + + /** + * 폐기 후보 삭제 + */ + public function removeDeprecation(int $id): void + { + ApiDeprecation::where('id', $id)->delete(); + } + + /** + * 폐기 후보 목록 조회 + */ + public function getDeprecations(?string $status = null): Collection + { + $query = ApiDeprecation::with('creator') + ->orderByDesc('created_at'); + + if ($status) { + $query->where('status', $status); + } + + return $query->get(); + } + + /** + * 인기 API 조회 (상위 N개) + */ + public function getPopularApis(int $limit = 20): Collection + { + return $this->getUsageStats()->take($limit); + } + + /** + * 최근 사용되지 않은 API (N일 이상) + */ + public function getStaleApis(int $days = 30): Collection + { + $cutoffDate = now()->subDays($days); + + // 최근 호출된 API + $recentlyUsed = ApiHistory::select('endpoint', 'method') + ->where('created_at', '>=', $cutoffDate) + ->distinct() + ->get() + ->map(fn ($item) => $item->endpoint . '|' . $item->method) + ->toArray(); + + // 전체 사용 통계에서 최근 사용된 것 제외 + return $this->getUsageStats() + ->filter(function ($item) use ($recentlyUsed) { + $key = $item->endpoint . '|' . $item->method; + + return ! in_array($key, $recentlyUsed); + }); + } + + /** + * 일별 API 호출 추이 + */ + public function getDailyTrend(int $days = 30): Collection + { + return ApiHistory::select(DB::raw('DATE(created_at) as date')) + ->selectRaw('COUNT(*) as call_count') + ->selectRaw('COUNT(DISTINCT CONCAT(endpoint, "|", method)) as unique_endpoints') + ->where('created_at', '>=', now()->subDays($days)) + ->groupBy('date') + ->orderBy('date') + ->get(); + } +} \ No newline at end of file diff --git a/resources/views/dev-tools/api-explorer/index.blade.php b/resources/views/dev-tools/api-explorer/index.blade.php index 202a9e78..5e37ac2c 100644 --- a/resources/views/dev-tools/api-explorer/index.blade.php +++ b/resources/views/dev-tools/api-explorer/index.blade.php @@ -213,6 +213,13 @@ + + + + + + + + + + + +
+
+
{{ $comparison['summary']['total'] }}
+
전체 API
+
+
+
{{ $comparison['summary']['used_count'] }}
+
사용중 API
+
+
+
{{ $comparison['summary']['unused_count'] }}
+
미사용 API
+
+
+
{{ $comparison['summary']['deprecation_count'] }}
+
폐기 후보
+
+
+ + +
+
+
+ 사용중 API ({{ count($comparison['used']) }}) +
+
+ 미사용 API ({{ count($comparison['unused']) }}) +
+
+ 폐기 후보 ({{ count($deprecations) }}) +
+
+ 호출 추이 +
+
+ + +
+
+ + + + + + + + + + + + + + + @forelse($comparison['used'] as $api) + + + + + + + + + + + @empty + + + + @endforelse + +
메서드엔드포인트호출 수성공실패평균 응답마지막 호출작업
+ + {{ $api['method'] }} + + +
{{ $api['endpoint'] }}
+ @if($api['summary']) +
{{ $api['summary'] }}
+ @endif +
{{ number_format($api['call_count']) }}{{ number_format($api['success_count']) }}{{ number_format($api['error_count']) }} + @if($api['avg_duration_ms']) + {{ number_format($api['avg_duration_ms'], 0) }}ms + @else + - + @endif + + @if($api['last_called_at']) + {{ \Carbon\Carbon::parse($api['last_called_at'])->diffForHumans() }} + @else + - + @endif + + @if(!$api['deprecation']) + + @else + + {{ $api['deprecation']->status_label }} + + @endif +
+ 사용된 API가 없습니다. +
+
+
+ + +
+
+ + + + + + + + + + + + @forelse($comparison['unused'] as $api) + + + + + + + + @empty + + + + @endforelse + +
메서드엔드포인트태그상태작업
+ + {{ $api['method'] }} + + +
{{ $api['endpoint'] }}
+ @if($api['summary']) +
{{ $api['summary'] }}
+ @endif +
+ @foreach($api['tags'] ?? [] as $tag) + {{ $tag }} + @endforeach + + @if($api['deprecation']) + + {{ $api['deprecation']->status_label }} + + @else + - + @endif + + @if(!$api['deprecation']) + + @endif +
+ 미사용 API가 없습니다. +
+
+
+ + +
+
+ + + + + + + + + + + + + + + @forelse($deprecations as $dep) + + + + + + + + + + + @empty + + + + @endforelse + +
메서드엔드포인트상태사유예정일등록자등록일작업
+ + {{ $dep->method }} + + +
{{ $dep->endpoint }}
+
+ + {{ $dep->reason ?? '-' }} + {{ $dep->scheduled_date?->format('Y-m-d') ?? '-' }} + + {{ $dep->creator?->name ?? '-' }} + + {{ $dep->created_at->format('Y-m-d') }} + +
+ + +
+
+ 폐기 후보가 없습니다. +
+
+
+ + +
+
+
+

최근 30일 API 호출 추이

+
+ + + + + + + + + + @forelse($dailyTrend as $day) + + + + + + @empty + + + + @endforelse + +
날짜호출 수고유 엔드포인트
{{ $day->date }}{{ number_format($day->call_count) }}{{ $day->unique_endpoints }}
+ 데이터가 없습니다. +
+
+
+
+
+
+ + + + +@endsection + +@push('scripts') + +@endpush \ No newline at end of file diff --git a/routes/web.php b/routes/web.php index 3f8a4d79..5c6cc1f0 100644 --- a/routes/web.php +++ b/routes/web.php @@ -324,26 +324,36 @@ 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::get('/templates/{endpoint}', [ApiExplorerController::class, 'templatesForEndpoint'])->name('templates.index'); + Route::post('/templates', [ApiExplorerController::class, 'saveTemplate'])->name('templates.store'); 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::post('/environments', [ApiExplorerController::class, 'saveEnvironment'])->name('environments.store'); Route::delete('/environments/{id}', [ApiExplorerController::class, 'deleteEnvironment'])->name('environments.destroy'); // 사용자 목록 (인증용) Route::get('/users', [ApiExplorerController::class, 'users'])->name('users'); + + // API 사용 현황 및 폐기 관리 + Route::get('/usage', [ApiExplorerController::class, 'usage'])->name('usage'); + Route::get('/usage/stats', [ApiExplorerController::class, 'usageStats'])->name('usage.stats'); + Route::get('/usage/trend', [ApiExplorerController::class, 'dailyTrend'])->name('usage.trend'); + Route::get('/usage/popular', [ApiExplorerController::class, 'popularApis'])->name('usage.popular'); + Route::get('/usage/stale', [ApiExplorerController::class, 'staleApis'])->name('usage.stale'); + + // 폐기 후보 관리 + Route::get('/deprecations', [ApiExplorerController::class, 'deprecations'])->name('deprecations.index'); + Route::post('/deprecations', [ApiExplorerController::class, 'addDeprecation'])->name('deprecations.store'); + Route::post('/deprecations/bulk-unused', [ApiExplorerController::class, 'addAllUnusedAsDeprecation'])->name('deprecations.bulk-unused'); + Route::put('/deprecations/{id}', [ApiExplorerController::class, 'updateDeprecation'])->name('deprecations.update'); + Route::delete('/deprecations/{id}', [ApiExplorerController::class, 'removeDeprecation'])->name('deprecations.destroy'); }); }); });