Files
sam-manage/app/Services/ApiExplorer/OpenApiParserService.php
hskwon bfbf4d3225 feat(api-explorer): Phase 1 기본 구조 및 OpenAPI 파싱 구현
- Config: api-explorer.php (환경, 보안, 캐시 설정)
- Migration: api_bookmarks, api_templates, api_histories, api_environments
- Model: ApiBookmark, ApiTemplate, ApiHistory, ApiEnvironment
- Service: OpenApiParserService, ApiRequestService, ApiExplorerService
- Controller: ApiExplorerController (CRUD, 실행, 히스토리)
- View: 3-Panel 레이아웃 (sidebar, request, response, history)
- Route: 23개 엔드포인트 등록

Swagger UI 대체 개발 도구, HTMX 기반 SPA 경험
2025-12-17 21:06:41 +09:00

311 lines
8.5 KiB
PHP

<?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;
}
}