API 레퍼런스에 요청 예시(raw HTTP)·응답 예시(envelope 전문)를 표준 포맷에 편입하고, 전 대상(코어+전 모듈+전 플러그인)에 방출했다. 예시 결함 8종 (multipart·Host 마스킹·write 실측·path 치환·현실적 바디·query 반영·중복 제거·민감값 마스킹)을 스캐폴더/프로브 공용 수정으로 근본 해소했다. 추가로 검증에서 드러난 결함을 마감했다: - 응답 예시 body 의 절대 URL(페이지네이터·콜백) 호스트를 placeholder 로 마스킹(maskResponseHost) — 요청 Host 헤더만 마스킹되던 누락 보완, 확장 39건 정정 - api:docgen 커맨드 통합 테스트 신설(라우트 수집·확장 라우팅·rules 추출· 멱등 재생성·check drift) - module-routing.md URL 스킴 오류 정정(/api/admin → /api/modules) - README·cheatsheet·api-documentation 에 API 문서 커맨드/레퍼런스 진입점 반영 전 대상 3칸 완료로 api-doc-coverage 룰을 warn→error 승격.
443 lines
17 KiB
PHP
443 lines
17 KiB
PHP
<?php
|
|
|
|
namespace App\Support\ApiDoc;
|
|
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Routing\Route;
|
|
use Illuminate\Support\Facades\Route as RouteFacade;
|
|
use Illuminate\Support\Str;
|
|
|
|
/**
|
|
* API 라우트 인벤토리
|
|
*
|
|
* 등록된 라우트를 전수 수집하여 API 문서 생성에 필요한 메타데이터
|
|
* (메서드·URI·라우트명·소유 확장·권한·컨트롤러 액션)로 정규화합니다.
|
|
*/
|
|
class ApiRouteInventory
|
|
{
|
|
/**
|
|
* API 라우트를 전수 수집하여 정규화된 배열로 반환합니다.
|
|
*
|
|
* @param string|null $scope 범위 필터 (null=전체, 'core', 'module:{id}', 'plugin:{id}')
|
|
* @return array<int, array<string, mixed>> 정규화된 라우트 메타데이터 목록
|
|
*/
|
|
public function collect(?string $scope = null): array
|
|
{
|
|
$routes = [];
|
|
|
|
foreach (RouteFacade::getRoutes() as $route) {
|
|
/** @var Route $route */
|
|
$uri = $route->uri();
|
|
|
|
if (! Str::startsWith($uri, 'api/')) {
|
|
continue;
|
|
}
|
|
|
|
$name = $route->getName() ?? '';
|
|
$owner = $this->resolveOwner($name, $uri);
|
|
|
|
if ($scope !== null && ! $this->matchesScope($owner, $scope)) {
|
|
continue;
|
|
}
|
|
|
|
foreach ($this->normalizeRoute($route, $uri, $name, $owner) as $entry) {
|
|
$routes[] = $entry;
|
|
}
|
|
}
|
|
|
|
// route:list 는 활성 확장만 노출한다. 비활성/미설치 확장을 명시 범위로 지정하면
|
|
// (module:{id} / plugin:{id}) 등록된 라우트가 0건이 되므로, 그 확장의 번들 라우트
|
|
// 파일을 프로바이더와 동일한 prefix/name 규약으로 임시 라우터에 로드해 폴백 수집한다.
|
|
if ($routes === [] && $scope !== null && $scope !== 'core' && $scope !== 'all') {
|
|
$routes = $this->collectFromBundledFiles($scope);
|
|
}
|
|
|
|
usort($routes, fn ($a, $b) => [$a['owner']['key'], $a['uri'], $a['method']] <=> [$b['owner']['key'], $b['uri'], $b['method']]);
|
|
|
|
return $routes;
|
|
}
|
|
|
|
/**
|
|
* 하나의 라우트를 HTTP 메서드별 정규화 엔트리 배열로 변환합니다.
|
|
*
|
|
* @param Route $route 라우트 인스턴스
|
|
* @param string $uri 라우트 URI ('api/' prefix 포함)
|
|
* @param string $name 라우트명
|
|
* @param array{type: string, id: string|null, key: string} $owner 소유 주체
|
|
* @return array<int, array<string, mixed>> 정규화된 엔트리 목록
|
|
*/
|
|
private function normalizeRoute(Route $route, string $uri, string $name, array $owner): array
|
|
{
|
|
$entries = [];
|
|
|
|
$middleware = $this->gatherMiddleware($route);
|
|
|
|
foreach ($this->httpMethods($route) as $method) {
|
|
$entries[] = [
|
|
'method' => $method,
|
|
'uri' => '/'.ltrim($uri, '/'),
|
|
'name' => $name,
|
|
'owner' => $owner,
|
|
'action' => $route->getActionName(),
|
|
'controller' => $this->controllerClass($route),
|
|
'controller_method' => $this->controllerMethod($route),
|
|
'middleware' => $middleware,
|
|
'permission' => $this->resolvePermission($middleware),
|
|
'path_params' => $this->pathParams($uri),
|
|
'path_bindings' => $this->pathBindings($route),
|
|
'domain_group' => $this->domainGroup($name, $uri, $owner),
|
|
];
|
|
}
|
|
|
|
return $entries;
|
|
}
|
|
|
|
/**
|
|
* 라우트의 미들웨어 목록을 안전하게 수집합니다.
|
|
*
|
|
* `gatherMiddleware()` 는 컨트롤러 정의 미들웨어를 읽기 위해 컨트롤러를 인스턴스화한다.
|
|
* 비활성/미설치 확장은 서비스 바인딩이 부팅되지 않아 인스턴스화가 실패할 수 있으므로,
|
|
* 실패 시 라우트에 직접 부여된 정적 미들웨어(`middleware()`)로 폴백한다.
|
|
*
|
|
* @param Route $route 라우트 인스턴스
|
|
* @return array<int, string> 미들웨어 목록
|
|
*/
|
|
private function gatherMiddleware(Route $route): array
|
|
{
|
|
try {
|
|
return $route->gatherMiddleware();
|
|
} catch (\Throwable) {
|
|
return $route->middleware();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 비활성/미설치 확장의 번들 라우트 파일을 임시 라우터에 로드해 수집합니다.
|
|
*
|
|
* 프로바이더(`ModuleRouteServiceProvider`/`PluginRouteServiceProvider`)와 동일한
|
|
* prefix(`api/{modules|plugins}/{id}`)·name(`api.{modules|plugins}.{id}.`)·`api`
|
|
* 미들웨어 그룹으로 `src/routes/api.php` 를 라우터에 로드한다. `api/` 로 시작하는
|
|
* 라우트만 대상이므로 web(admin) 라우트는 자동 제외된다(그 라우트는 API 문서 대상이 아니다).
|
|
*
|
|
* 라우트 파일은 `use Illuminate\Support\Facades\Route` 로 전역 파사드에 등록하므로,
|
|
* 등록 전 라우트 이름 스냅샷을 떠서 이번 로드로 추가된 라우트만 골라낸다. 이 커맨드는
|
|
* CLI 단발 프로세스라 전역 라우트 테이블 추가가 웹 요청에 영향을 주지 않는다.
|
|
*
|
|
* @param string $scope 범위 필터 (`module:{id}` / `plugin:{id}`)
|
|
* @return array<int, array<string, mixed>> 정규화된 라우트 메타데이터 목록
|
|
*/
|
|
private function collectFromBundledFiles(string $scope): array
|
|
{
|
|
if (! preg_match('/^(module|plugin):(.+)$/', $scope, $m)) {
|
|
return [];
|
|
}
|
|
|
|
[$type, $id] = [$m[1], $m[2]];
|
|
$dir = $type === 'module' ? 'modules' : 'plugins';
|
|
$apiRouteFile = base_path("{$dir}/_bundled/{$id}/src/routes/api.php");
|
|
|
|
if (! is_file($apiRouteFile)) {
|
|
return [];
|
|
}
|
|
|
|
$urlPrefix = "api/{$dir}/{$id}";
|
|
$namePrefix = "api.{$dir}.{$id}.";
|
|
|
|
try {
|
|
RouteFacade::prefix($urlPrefix)
|
|
->name($namePrefix)
|
|
->middleware('api')
|
|
->group($apiRouteFile);
|
|
RouteFacade::getRoutes()->refreshNameLookups();
|
|
} catch (\Throwable) {
|
|
return [];
|
|
}
|
|
|
|
// 이 확장 소유로 확정된 라우트를 전역 라우트 테이블에서 prefix(uri/name) 로 직접 골라낸다.
|
|
// object-id 스냅샷 방식은 refreshNameLookups() 가 RouteCollection 을 재색인하며 객체를
|
|
// 다시 만들어 dedup 이 어긋나므로(활성 확장 로드 순서·중복 로드에 취약) 쓰지 않는다.
|
|
// 대상 확장은 uri 가 `api/{dir}/{id}/`, name 이 `api.{dir}.{id}.` 로 시작해 결정적으로 식별된다.
|
|
$seen = [];
|
|
$routes = [];
|
|
|
|
foreach (RouteFacade::getRoutes() as $route) {
|
|
/** @var Route $route */
|
|
$uri = $route->uri();
|
|
|
|
if (! Str::startsWith($uri, 'api/')) {
|
|
continue;
|
|
}
|
|
|
|
$name = $route->getName() ?? '';
|
|
$owner = $this->resolveOwner($name, $uri);
|
|
|
|
// 폴백 대상 확장 소유로 확정되지 않으면(코어·타 확장) 제외
|
|
if ($owner['key'] !== $scope) {
|
|
continue;
|
|
}
|
|
|
|
foreach ($this->normalizeRoute($route, $uri, $name, $owner) as $entry) {
|
|
// 파일이 이미 한 번 로드돼 있으면 같은 (method, uri) 가 중복될 수 있어 dedup 한다.
|
|
$dedupKey = $entry['method'].' '.$entry['uri'];
|
|
if (isset($seen[$dedupKey])) {
|
|
continue;
|
|
}
|
|
$seen[$dedupKey] = true;
|
|
$routes[] = $entry;
|
|
}
|
|
}
|
|
|
|
return $routes;
|
|
}
|
|
|
|
/**
|
|
* 라우트명/URI 로 소유 주체(코어/모듈/플러그인)를 판별합니다.
|
|
*
|
|
* @param string $name 라우트명
|
|
* @param string $uri 라우트 URI
|
|
* @return array{type: string, id: string|null, key: string} 소유 주체 정보
|
|
*/
|
|
private function resolveOwner(string $name, string $uri): array
|
|
{
|
|
if (preg_match('/^api\.modules\.([^.]+)\./', $name, $m) && $this->isRealExtensionId($m[1])) {
|
|
return ['type' => 'module', 'id' => $m[1], 'key' => 'module:'.$m[1]];
|
|
}
|
|
|
|
if (preg_match('/^api\.plugins\.([^.]+)\./', $name, $m) && $this->isRealExtensionId($m[1])) {
|
|
return ['type' => 'plugin', 'id' => $m[1], 'key' => 'plugin:'.$m[1]];
|
|
}
|
|
|
|
// 라우트명이 비어도 URI prefix 로 보조 판별
|
|
if (preg_match('#^api/modules/([^/{]+)/#', $uri, $m) && $this->isRealExtensionId($m[1])) {
|
|
return ['type' => 'module', 'id' => $m[1], 'key' => 'module:'.$m[1]];
|
|
}
|
|
|
|
if (preg_match('#^api/plugins/([^/{]+)/#', $uri, $m) && $this->isRealExtensionId($m[1])) {
|
|
return ['type' => 'plugin', 'id' => $m[1], 'key' => 'plugin:'.$m[1]];
|
|
}
|
|
|
|
// 코어가 제공하는 확장 메타/에셋 라우트(/api/modules/{identifier}/license,
|
|
// /api/modules/bundle.js 등)는 실제 확장 소유가 아니라 코어 소유다.
|
|
return ['type' => 'core', 'id' => null, 'key' => 'core'];
|
|
}
|
|
|
|
/**
|
|
* 세그먼트가 실제 확장 식별자인지 판별합니다 (플레이스홀더/에셋 라우트 제외).
|
|
*
|
|
* @param string $segment URI/라우트명 세그먼트
|
|
* @return bool 실제 확장 식별자 여부
|
|
*/
|
|
private function isRealExtensionId(string $segment): bool
|
|
{
|
|
// {identifier} 같은 라우트 파라미터, bundle/assets 같은 코어 에셋 라우트 제외.
|
|
// 실제 확장 식별자는 vendor-name 형태로 하이픈을 포함한다.
|
|
if (Str::startsWith($segment, '{') || in_array($segment, ['assets', 'bundle', 'bundle.js', 'bundle.css'], true)) {
|
|
return false;
|
|
}
|
|
|
|
return Str::contains($segment, '-');
|
|
}
|
|
|
|
/**
|
|
* 소유 주체가 지정된 범위 필터에 매칭되는지 확인합니다.
|
|
*
|
|
* @param array{type: string, id: string|null, key: string} $owner 소유 주체
|
|
* @param string $scope 범위 필터
|
|
* @return bool 매칭 여부
|
|
*/
|
|
private function matchesScope(array $owner, string $scope): bool
|
|
{
|
|
if ($scope === 'all') {
|
|
return true;
|
|
}
|
|
|
|
if ($scope === 'core') {
|
|
return $owner['type'] === 'core';
|
|
}
|
|
|
|
return $owner['key'] === $scope;
|
|
}
|
|
|
|
/**
|
|
* 라우트의 HTTP 메서드 목록을 반환합니다 (HEAD 제외).
|
|
*
|
|
* @param Route $route 라우트 인스턴스
|
|
* @return array<int, string> HTTP 메서드 목록
|
|
*/
|
|
private function httpMethods(Route $route): array
|
|
{
|
|
return array_values(array_filter(
|
|
$route->methods(),
|
|
fn ($m) => $m !== 'HEAD'
|
|
));
|
|
}
|
|
|
|
/**
|
|
* 컨트롤러 클래스명을 반환합니다.
|
|
*
|
|
* @param Route $route 라우트 인스턴스
|
|
* @return string|null 컨트롤러 FQCN (클로저면 null)
|
|
*/
|
|
private function controllerClass(Route $route): ?string
|
|
{
|
|
$action = $route->getActionName();
|
|
|
|
if ($action === 'Closure' || ! Str::contains($action, '@')) {
|
|
return null;
|
|
}
|
|
|
|
return Str::before($action, '@');
|
|
}
|
|
|
|
/**
|
|
* 컨트롤러 메서드명을 반환합니다.
|
|
*
|
|
* @param Route $route 라우트 인스턴스
|
|
* @return string|null 메서드명 (클로저면 null)
|
|
*/
|
|
private function controllerMethod(Route $route): ?string
|
|
{
|
|
$action = $route->getActionName();
|
|
|
|
if ($action === 'Closure' || ! Str::contains($action, '@')) {
|
|
return null;
|
|
}
|
|
|
|
return Str::after($action, '@');
|
|
}
|
|
|
|
/**
|
|
* 미들웨어 목록에서 permission 식별자를 추출합니다.
|
|
*
|
|
* @param array<int, string> $middleware 미들웨어 목록
|
|
* @return string|null permission 식별자 (예: core.users.read)
|
|
*/
|
|
private function resolvePermission(array $middleware): ?string
|
|
{
|
|
foreach ($middleware as $mw) {
|
|
if (! Str::contains($mw, 'PermissionMiddleware') && ! Str::startsWith($mw, 'permission:')) {
|
|
continue;
|
|
}
|
|
|
|
$args = Str::after($mw, ':');
|
|
// permission:admin,core.users.read,except:self:user → core.users.read
|
|
foreach (explode(',', $args) as $part) {
|
|
$part = trim($part);
|
|
if (Str::contains($part, '.') && ! Str::startsWith($part, 'except')) {
|
|
return $part;
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* URI 의 path 파라미터 목록을 추출합니다.
|
|
*
|
|
* @param string $uri 라우트 URI
|
|
* @return array<int, string> path 파라미터명 목록
|
|
*/
|
|
private function pathParams(string $uri): array
|
|
{
|
|
preg_match_all('/\{([^}?]+)\??\}/', $uri, $m);
|
|
|
|
return $m[1] ?? [];
|
|
}
|
|
|
|
/**
|
|
* path 파라미터명 → 바인딩된 Eloquent 모델 클래스 맵을 추출합니다.
|
|
*
|
|
* @param Route $route 라우트 인스턴스
|
|
* @return array<string, string> 파라미터명 => 모델 FQCN
|
|
*/
|
|
private function pathBindings(Route $route): array
|
|
{
|
|
$bindings = [];
|
|
|
|
try {
|
|
foreach ($route->signatureParameters() as $param) {
|
|
$type = $param->getType();
|
|
|
|
if ($type instanceof \ReflectionNamedType && ! $type->isBuiltin()) {
|
|
$class = $type->getName();
|
|
|
|
if (is_subclass_of($class, Model::class)) {
|
|
$bindings[$param->getName()] = $class;
|
|
}
|
|
}
|
|
}
|
|
} catch (\Throwable) {
|
|
// signatureParameters 실패 시 빈 맵
|
|
}
|
|
|
|
return $bindings;
|
|
}
|
|
|
|
/**
|
|
* 문서 파일 그룹핑용 도메인 키를 산출합니다.
|
|
*
|
|
* @param string $name 라우트명
|
|
* @param string $uri 라우트 URI
|
|
* @param array{type: string, id: string|null, key: string} $owner 소유 주체
|
|
* @return string 도메인 키 (예: users, products)
|
|
*/
|
|
private function domainGroup(string $name, string $uri, array $owner): string
|
|
{
|
|
// 라우트명 구조: api.{context}.{resource}.{action} 또는
|
|
// api.modules.{ext-id}.{context}.{resource}.{action}
|
|
// 도메인 = 앞쪽 연속된 context/소유 세그먼트를 걷어낸 뒤의 첫 세그먼트(리소스).
|
|
// 위치 기반이므로 'auth' 같은 리소스가 skip 리스트에 걸려 소실되지 않는다.
|
|
// 'modules'/'plugins' 는 확장 소유 prefix(api.modules.{id}.*)로도, 코어 확장관리
|
|
// 리소스(api.admin.modules.*)로도 쓰인다. 소유 prefix 는 owner['id'] 스킵으로 이미
|
|
// 걷히므로, leading 에는 순수 컨텍스트(api/admin/user/me/public)만 둔다.
|
|
$leading = ['api', 'admin', 'user', 'me', 'public'];
|
|
$segments = array_values(array_filter(explode('.', $name), fn ($s) => $s !== ''));
|
|
|
|
$i = 0;
|
|
while ($i < count($segments)) {
|
|
$seg = $segments[$i];
|
|
// 확장 소유 prefix 세그먼트(modules/plugins + ext-id)만 스킵
|
|
$isOwnerPrefix = in_array($seg, ['modules', 'plugins'], true) && isset($segments[$i + 1]) && $segments[$i + 1] === $owner['id'];
|
|
$isLeading = in_array($seg, $leading, true) || $isOwnerPrefix || ($owner['id'] !== null && $seg === $owner['id']);
|
|
|
|
if (! $isLeading) {
|
|
break;
|
|
}
|
|
$i++;
|
|
}
|
|
|
|
// context(me 등) 직후가 REST 액션명이면 리소스가 아니라 그 컨텍스트가 도메인이다
|
|
// (api.me.show → 'me', api.me.destroy → 'me'). 아니면 그 세그먼트가 리소스.
|
|
$restActions = ['show', 'index', 'store', 'update', 'destroy', 'edit', 'create'];
|
|
if (isset($segments[$i])) {
|
|
if (in_array($segments[$i], $restActions, true) && $i > 0 && in_array($segments[$i - 1], $leading, true)) {
|
|
return Str::slug($segments[$i - 1]) ?: 'misc';
|
|
}
|
|
|
|
return Str::slug($segments[$i]) ?: 'misc';
|
|
}
|
|
|
|
// 리소스 세그먼트가 없으면(context 직속) 마지막 컨텍스트를 도메인으로.
|
|
if ($i > 0 && isset($segments[$i - 1]) && in_array($segments[$i - 1], $leading, true)) {
|
|
return Str::slug($segments[$i - 1]) ?: 'misc';
|
|
}
|
|
|
|
// 라우트명이 비었으면 URI 에서 소유/컨텍스트 prefix 를 걷어낸 첫 리소스 세그먼트
|
|
$parts = array_values(array_filter(explode('/', $uri), fn ($p) => $p !== '' && ! Str::startsWith($p, '{')));
|
|
$uriLeading = ['api', 'admin', 'user', 'me', 'public'];
|
|
foreach ($parts as $p) {
|
|
if (in_array($p, $uriLeading, true) || ($owner['id'] !== null && $p === $owner['id'])) {
|
|
continue;
|
|
}
|
|
if (in_array($p, ['modules', 'plugins'], true) && ($owner['id'] !== null)) {
|
|
continue;
|
|
}
|
|
|
|
return Str::slug($p) ?: 'misc';
|
|
}
|
|
|
|
return 'misc';
|
|
}
|
|
}
|