Files
Gnuboard7/app/Console/Commands/ApiDocgenCommand.php
T
HeuJung 70452745c4 feat(api-docs): API 레퍼런스 문서 전면화 — 추출 파이프라인·전 대상 문서화·audit 강제
코드에만 존재하던 REST API 계약(약 663 엔드포인트)을 코어/확장 책임별로
분리된 마크다운 레퍼런스로 전면 문서화한다. 674개 규모에서 수기 문서는
반드시 drift 하므로 "코드 추출 → 스캐폴딩 → 사람이 서술 채움 → 하네스가
커버리지 강제" 하이브리드로 구성했다.

추출 파이프라인 (app/Support/ApiDoc):
- ApiRouteInventory / FormRequestIntrospector / ApiEndpointProbe(실측 HTTP) /
 ResponseSchemaInferrer / ApiDocScaffolder / ColumnCommentResolver /
 ResourceFieldDescriber / ParameterDescriber
- api:docgen 커맨드(--scope/--seed/--check/--dry-run/--base-url/--user) +
 응답/파라미터 in-place 백필 커맨드 2종(재생성 없이 TODO 셀만 치환, 멱등)
- ApiDocSampleSeeder 계약 + 코어/확장 시더로 실측용 완전 샘플 멱등 생성

문서화 (실측 기반, GET read-only 실호출):
- 코어 291엔드포인트 35파일(docs/backend/api) + 규정 docs/backend/api-documentation.md
- 확장: ecommerce(231)·board(80)·page(17)·hello_module(2)·pay_kginicis(22)·
 gdpr(15)·ckeditor5(2)·marketing(2)·verification_kginicis(1)
- 표준 4구성(헤더·요청 파라미터·응답 필드·에러 표) + 엔드포인트 용도 서술
- 파라미터 용도·응답 필드 설명 셀 전수 채움(도메인 지식 수기)

하네스:
- audit 룰 api-doc-coverage — API 표면(라우트/컨트롤러/FormRequest/Resource)
 변경 시 대응 문서 미동반이면 차단. 전 대상 문서 완비로 error 승격
- file-rules 리마인더(컨트롤러/라우트 편집 시), coverage.json, dev-dashboard 카드
- docs/backend/routing.md 확장 공개 API URL 스킴 정정(/api/modules|plugins/{id})
- /AGENTS.md/docs-index 동기
2026-07-08 18:22:33 +09:00

545 lines
21 KiB
PHP

<?php
namespace App\Console\Commands;
use App\Contracts\ApiDoc\ApiDocSampleSeeder;
use App\Models\Module;
use App\Models\Plugin;
use App\Models\Template;
use App\Models\User;
use App\Support\ApiDoc\ApiDocSampleService;
use App\Support\ApiDoc\ApiDocScaffolder;
use App\Support\ApiDoc\ApiEndpointProbe;
use App\Support\ApiDoc\ApiRouteInventory;
use App\Support\ApiDoc\ColumnCommentResolver;
use App\Support\ApiDoc\FormRequestIntrospector;
use App\Support\ApiDoc\ResponseSchemaInferrer;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
/**
* API 레퍼런스 문서 생성 커맨드
*
* 등록된 API 라우트를 전수 수집하고, GET 엔드포인트는 실제 HTTP 호출로 응답을
* 실측하여 요청 파라미터·응답 필드를 담은 마크다운 레퍼런스 문서를 생성합니다.
*
* @generated 블록만 갱신하며 사람이 작성한 서술은 보존합니다.
*/
class ApiDocgenCommand extends Command
{
/**
* @var string 커맨드 시그니처
*/
protected $signature = 'api:docgen
{--scope=core : 범위 (core, module:vendor-id, plugin:vendor-id, all)}
{--base-url= : 실측 기준 URL (미지정 시 .env APP_URL)}
{--user= : 실측 토큰 발급 대상 사용자 ID}
{--seed : 실측 전 완전 샘플 데이터를 시드 (개발 환경 전용)}
{--check : 생성하지 않고 누락/미실측만 리포트}
{--dry-run : 생성 대상만 출력}';
/**
* @var string 커맨드 설명
*/
protected $description = 'API 레퍼런스 문서를 실측 기반으로 생성/갱신합니다';
/**
* 커맨드를 실행합니다.
*
* @param ApiRouteInventory $inventory 라우트 인벤토리
* @param FormRequestIntrospector $introspector FormRequest 분석기
* @param ResponseSchemaInferrer $inferrer 응답 스키마 추론기
* @param ApiDocScaffolder $scaffolder 스캐폴딩 생성기
* @param ColumnCommentResolver $commentResolver 컬럼 주석 해석기
* @return int 종료 코드
*/
public function handle(
ApiRouteInventory $inventory,
FormRequestIntrospector $introspector,
ResponseSchemaInferrer $inferrer,
ApiDocScaffolder $scaffolder,
ColumnCommentResolver $commentResolver
): int {
$scope = (string) $this->option('scope');
$routes = $inventory->collect($scope);
if ($routes === []) {
$this->warn("범위 '{$scope}' 에 해당하는 API 라우트가 없습니다.");
return self::SUCCESS;
}
$this->info(count($routes)."개 라우트 수집 (scope={$scope})");
// 도메인 파일 단위로 그룹핑
$grouped = [];
foreach ($routes as $route) {
$file = $this->targetFile($route);
$grouped[$file][] = $route;
}
if ($this->option('dry-run')) {
foreach ($grouped as $file => $items) {
$this->line(sprintf(' %s (%d endpoints)', $file, count($items)));
}
return self::SUCCESS;
}
// --seed: 도메인별 완전 샘플을 시드해 상세 GET 실측 시 null 응답을 최소화
// (인증보다 먼저 수행해 완전 샘플 사용자로 토큰을 발급 → /me 응답도 채워짐)
$sampleMap = [];
if ($this->option('seed')) {
if (app()->environment('production')) {
$this->error('--seed 는 개발 환경 전용입니다 (production 차단).');
return self::FAILURE;
}
// 코어 샘플 우선 시드 — 확장 샘플의 소유자/actor 로 쓰이는 완전 사용자를 확보한다.
$sampleMap = (new ApiDocSampleService)->seed();
$this->info('완전 샘플 시드: '.count($sampleMap).'개 도메인 (코어)');
// 확장 소유 라우트가 있으면 그 확장의 규약 시더를 발견해 도메인 샘플을 병합한다.
foreach ($this->discoverExtensionSeeders($routes) as $label => $seeder) {
$extMap = $seeder->seed();
$sampleMap = array_merge($sampleMap, $extMap);
$this->info('완전 샘플 시드: '.count($extMap)."개 도메인 ({$label})");
}
}
$probe = new ApiEndpointProbe($this->option('base-url') ?: null);
// 인증 사용자: --user 명시 > 완전 샘플 사용자(/me 응답 충실) > 첫 사용자
$authUserId = $this->option('user')
? (int) $this->option('user')
: $this->sampleUserId($sampleMap);
$probed = $probe->authenticate($authUserId);
if (! $probed) {
$this->warn('실측 토큰 발급 실패 — 실측 없이 정적 추출만 진행합니다.');
} else {
$this->info('실측 기준 URL: '.$probe->baseUrl());
}
$stats = ['files' => 0, 'endpoints' => 0, 'probed' => 0, 'skipped' => 0];
$checkFindings = [];
foreach ($grouped as $file => $items) {
$sections = [];
$sectionKeys = [];
foreach ($items as $route) {
$request = $introspector->introspect($route['controller'], $route['controller_method']);
[$schema, $probeMeta] = $this->probeEndpoint($probe, $inferrer, $route, $probed, $sampleMap);
if ($probeMeta['skipped_reason'] === null) {
$stats['probed']++;
} else {
$stats['skipped']++;
$checkFindings[] = "{$route['method']} {$route['uri']} — {$probeMeta['skipped_reason']}";
}
$commentMap = $this->columnComments($route, $commentResolver, $sampleMap);
$sections[] = $scaffolder->endpointSection($route, $request, $schema, $probeMeta, $commentMap);
$sectionKeys[] = $route['name'] ?: $route['uri'];
$stats['endpoints']++;
}
if ($this->option('check')) {
if (! File::exists($file)) {
$checkFindings[] = "문서 파일 없음: {$file}";
}
continue;
}
$header = $this->documentHeader($file, $items[0]);
$existing = File::exists($file) ? File::get($file) : null;
$content = $scaffolder->mergeDocument($existing, $header, $sections, $sectionKeys);
File::ensureDirectoryExists(dirname($file));
File::put($file, $content);
$stats['files']++;
}
$probe->cleanup();
if ($this->option('check')) {
if ($checkFindings !== []) {
$this->warn('실측 제외/문서 누락 '.count($checkFindings).'건:');
foreach (array_slice($checkFindings, 0, 50) as $f) {
$this->line(' - '.$f);
}
return self::FAILURE;
}
$this->info('drift 없음.');
return self::SUCCESS;
}
$this->newLine();
$this->info(sprintf(
'완료: 파일 %d개, 엔드포인트 %d개 (실측 %d, 제외 %d)',
$stats['files'],
$stats['endpoints'],
$stats['probed'],
$stats['skipped']
));
return self::SUCCESS;
}
/**
* 수집된 라우트의 소유 확장에서 규약 위치의 샘플 시더를 발견합니다.
*
* 확장 컨트롤러 FQCN(`Modules\Vendor\Ext\Http\Controllers\...`)에서 확장 베이스
* 네임스페이스를 추출하고, `{Base}\Support\ApiDoc\ApiDocSampleService` 규약 클래스가
* 존재하며 ApiDocSampleSeeder 를 구현하면 인스턴스를 반환합니다. 각 확장은 1회만 처리합니다.
*
* @param array<int, array<string, mixed>> $routes 수집된 라우트 목록
* @return array<string, ApiDocSampleSeeder> 확장 라벨(key) => 시더 인스턴스
*/
private function discoverExtensionSeeders(array $routes): array
{
$seeders = [];
$seen = [];
foreach ($routes as $route) {
$owner = $route['owner'];
if ($owner['type'] === 'core' || $owner['id'] === null) {
continue;
}
$label = $owner['key'];
if (isset($seen[$label])) {
continue;
}
$seen[$label] = true;
$base = $this->extensionBaseNamespace($route['controller'] ?? null);
if ($base === null) {
continue;
}
$class = $base.'\\Support\\ApiDoc\\ApiDocSampleService';
if (class_exists($class) && is_subclass_of($class, ApiDocSampleSeeder::class)) {
$seeders[$label] = app($class);
}
}
return $seeders;
}
/**
* 확장 컨트롤러 FQCN 에서 확장 베이스 네임스페이스를 추출합니다.
*
* 두 컨트롤러 배치 규약을 모두 지원한다:
* - `\Http\Controllers\` (대다수 확장): `Modules\Sirsoft\Page\Http\Controllers\Admin\PageController`
* → `Modules\Sirsoft\Page`
* - `\Controllers\` (일부 플러그인): `Plugins\Sirsoft\PayKginicis\Controllers\PaymentSignatureController`
* → `Plugins\Sirsoft\PayKginicis`
*
* `\Http\Controllers\` 를 우선 판정하고, 없으면 `\Controllers\` 세그먼트 앞까지를 베이스로 본다.
*
* @param string|null $controller 컨트롤러 FQCN (클로저면 null)
* @return string|null 확장 베이스 네임스페이스 (추출 불가 시 null)
*/
private function extensionBaseNamespace(?string $controller): ?string
{
if ($controller === null) {
return null;
}
if (Str::contains($controller, '\\Http\\Controllers\\')) {
return Str::before($controller, '\\Http\\Controllers\\');
}
if (Str::contains($controller, '\\Controllers\\')) {
return Str::before($controller, '\\Controllers\\');
}
return null;
}
/**
* 엔드포인트를 실측하고 응답 스키마를 추론합니다.
*
* @param ApiEndpointProbe $probe 실측 프로브
* @param ResponseSchemaInferrer $inferrer 스키마 추론기
* @param array<string, mixed> $route 라우트 메타데이터
* @param bool $probed 실측 가능 여부
* @param array<string, array{model: class-string, key: string, value: string}> $sampleMap 도메인별 대표 샘플 맵
* @return array{0: array<string, mixed>|null, 1: array<string, mixed>} 스키마와 실측 메타
*/
private function probeEndpoint(ApiEndpointProbe $probe, ResponseSchemaInferrer $inferrer, array $route, bool $probed, array $sampleMap = []): array
{
if (! $probed) {
return [null, ['status' => null, 'skipped_reason' => 'no-token']];
}
$uri = $this->resolvePathParams($route, $sampleMap);
$result = $probe->probe($route['method'], $uri);
if (! $result['ok'] || $result['body'] === null) {
return [null, ['status' => $result['status'], 'skipped_reason' => $result['skipped_reason'] ?? ('http-'.$result['status'])]];
}
return [$inferrer->infer($result['body']), ['status' => $result['status'], 'skipped_reason' => null]];
}
/**
* URI 의 path 파라미터를 바인딩된 모델의 실제 레코드로 치환합니다.
*
* @param array<string, mixed> $route 라우트 메타데이터
* @param array<string, array{model: class-string, key: string, value: string}> $sampleMap 도메인별 대표 샘플 맵
* @return string 치환된 URI (치환 실패 시 원본 유지 → 프로브가 실측 제외)
*/
private function resolvePathParams(array $route, array $sampleMap = []): string
{
$uri = $route['uri'];
$domain = $route['domain_group'] ?? null;
// path 파라미터가 있으면: 완전 샘플 대표 레코드 우선, 없으면 첫 레코드 키로 치환
foreach ($route['path_params'] as $param) {
$modelClass = $route['path_bindings'][$param] ?? null;
// 라우트-모델 바인딩이 없는 확장 패턴(예: show(int $id))은 도메인 샘플 맵의
// 대표 모델로 폴백한다. 단, 파라미터명이 도메인의 단수 리소스명과 일치할 때만
// (pages/{page} → Page). {slug}/{hash}/{versionId} 등 route key 가 다른
// 문자열/보조 파라미터에는 폴백하지 않아 잘못된 치환(404)을 피한다.
if (! $modelClass && $domain !== null && isset($sampleMap[$domain]) && $this->paramMatchesDomain($param, $domain)) {
$modelClass = $sampleMap[$domain]['model'];
}
if (! $modelClass) {
// route-model binding 도 도메인 폴백도 없는 문자열 path 파라미터
// (예: board 의 boards/{slug}/posts/{id}). 도메인 대표 샘플이 명시한
// path_params 맵(param 명 => 실제 값)에 해당 param 이 있으면 그 값으로 치환한다.
// slug 라우팅을 쓰는 확장이 route key(id) 와 무관한 slug/id 조합을
// 실측할 수 있도록 하는 일반 경로다. (파라미터명 정확 일치만 허용 → 오치환 방지)
$explicit = $domain !== null ? ($sampleMap[$domain]['path_params'][$param] ?? null) : null;
if ($explicit !== null) {
$uri = str_replace('{'.$param.'}', (string) $explicit, $uri);
}
continue;
}
// 시드된 완전 샘플 중 같은 모델이 있으면 그 route key 를 우선 사용
$value = $this->sampleKeyForModel($modelClass, $sampleMap) ?? $this->firstRouteKey($modelClass);
if ($value !== null) {
$uri = str_replace('{'.$param.'}', (string) $value, $uri);
}
}
// 목록 GET 은 여러 행을 받아 필드별 non-null 대표 샘플을 확보한다
// (per_page=1 이면 첫 행이 우연히 비어 "항상 null" 처럼 보임).
if ($route['method'] === 'GET' && ! Str::contains($uri, '{')) {
return $uri.(Str::contains($uri, '?') ? '&' : '?').'per_page=25';
}
return $uri;
}
/**
* path 파라미터명이 도메인의 단수 리소스명과 일치하는지 판정합니다.
*
* 도메인 그룹은 복수형(pages)이고 라우트 파라미터는 단수(page)이므로,
* 파라미터명 == 도메인명 또는 파라미터명 == 도메인 단수형일 때만 매칭으로 본다.
* (page ↔ pages). {slug}/{hash}/{versionId} 는 어느 쪽과도 일치하지 않는다.
*
* @param string $param path 파라미터명
* @param string $domain 도메인 그룹명 (복수형 가능)
* @return bool 도메인 리소스 파라미터 여부
*/
private function paramMatchesDomain(string $param, string $domain): bool
{
return $param === $domain
|| $param === Str::singular($domain)
|| Str::plural($param) === $domain;
}
/**
* 라우트의 주 모델 컬럼 주석 맵을 반환합니다 (응답 필드 설명 기본값).
*
* path 파라미터에 바인딩된 모델을 우선하고, 없으면 도메인 샘플 맵의 대표
* 모델을 주 모델로 보고 그 테이블 주석을 사용합니다.
*
* @param array<string, mixed> $route 라우트 메타데이터
* @param ColumnCommentResolver $resolver 컬럼 주석 해석기
* @param array<string, array{model: class-string, key: string, value: string}> $sampleMap 도메인별 대표 샘플 맵
* @return array<string, string> 컬럼명 => 주석
*/
private function columnComments(array $route, ColumnCommentResolver $resolver, array $sampleMap = []): array
{
$bindings = $route['path_bindings'] ?? [];
// 마지막 path 바인딩 모델(가장 구체적인 리소스)을 주 모델로 사용
$modelClass = null;
foreach ($bindings as $class) {
$modelClass = $class;
}
// path 바인딩이 없으면(목록/단건 me 등) 도메인 샘플 맵 → 정적 힌트 순으로 유추
if (! $modelClass) {
$domain = $route['domain_group'] ?? null;
$modelClass = ($domain && isset($sampleMap[$domain]) ? $sampleMap[$domain]['model'] : null)
?? $this->domainModelHint($domain);
}
return $modelClass ? $resolver->forModel($modelClass) : [];
}
/**
* 샘플 맵으로 유추되지 않는 도메인의 주 모델을 정적 힌트로 매핑합니다.
*
* me/auth/profile/password 등은 path 바인딩도 없고 sampleMap 키(users)와도
* 도메인명이 달라 자동 유추가 안 되지만, 모두 User 필드를 반환합니다.
*
* @param string|null $domain 도메인 그룹명
* @return class-string|null 주 모델 FQCN (없으면 null)
*/
private function domainModelHint(?string $domain): ?string
{
if ($domain === null) {
return null;
}
$hints = [
'me' => User::class,
'auth' => User::class,
'profile' => User::class,
'password' => User::class,
'modules' => Module::class,
'plugins' => Plugin::class,
'templates' => Template::class,
];
return $hints[$domain] ?? null;
}
/**
* 완전 샘플 맵에서 샘플 사용자의 DB id 를 조회합니다 (토큰 발급 대상).
*
* @param array<string, array{model: class-string, key: string, value: string}> $sampleMap 대표 샘플 맵
* @return int|null 샘플 사용자 id (없으면 null → 첫 사용자로 fallback)
*/
private function sampleUserId(array $sampleMap): ?int
{
$users = $sampleMap['users'] ?? null;
if (! $users) {
return null;
}
return User::query()
->where($users['key'], $users['value'])
->value('id');
}
/**
* 시드된 완전 샘플 맵에서 해당 모델의 대표 route key 를 찾습니다.
*
* @param class-string $modelClass 모델 FQCN
* @param array<string, array{model: class-string, key: string, value: string}> $sampleMap 대표 샘플 맵
* @return string|null 대표 route key 값 (없으면 null)
*/
private function sampleKeyForModel(string $modelClass, array $sampleMap): ?string
{
foreach ($sampleMap as $sample) {
if ($sample['model'] === $modelClass) {
return $sample['value'];
}
}
return null;
}
/**
* 모델의 첫 레코드 route key 값을 반환합니다.
*
* @param class-string $modelClass 모델 FQCN
* @return mixed route key 값 (레코드 없으면 null)
*/
private function firstRouteKey(string $modelClass): mixed
{
try {
/** @var Model $model */
$model = new $modelClass;
$keyName = $model->getRouteKeyName();
$record = $modelClass::query()->orderBy($model->getKeyName())->first();
return $record?->getAttribute($keyName);
} catch (\Throwable) {
return null;
}
}
/**
* 라우트가 저장될 문서 파일 경로를 산출합니다.
*
* @param array<string, mixed> $route 라우트 메타데이터
* @return string 절대 파일 경로
*/
private function targetFile(array $route): string
{
$owner = $route['owner'];
$domain = $route['domain_group'];
if ($owner['type'] === 'core') {
return base_path("docs/backend/api/{$domain}.md");
}
$base = $owner['type'] === 'module' ? 'modules' : 'plugins';
return base_path("{$base}/_bundled/{$owner['id']}/docs/api/{$domain}.md");
}
/**
* 문서 헤더(제목 + TL;DR)를 생성합니다.
*
* @param string $file 문서 파일 경로
* @param array<string, mixed> $firstRoute 대표 라우트
* @return string 문서 헤더
*/
private function documentHeader(string $file, array $firstRoute): string
{
$domain = Str::headline(pathinfo($file, PATHINFO_FILENAME));
$owner = $firstRoute['owner'];
$ownerLabel = $owner['type'] === 'core' ? '코어' : "{$owner['type']} `{$owner['id']}`";
return <<<MD
# {$domain} API 레퍼런스
> **소유**: {$ownerLabel} · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다.
---
## TL;DR (5초 요약)
```text
1. 이 문서는 실제 API 호출로 실측한 {$domain} 엔드포인트 레퍼런스입니다
2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표
3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다
4. 갱신: 코드 변경 후 php artisan api:docgen 재실행
5. 설명(TODO) 칸은 사람이 채웁니다
```
---
MD;
}
}