코드에만 존재하던 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 동기
168 lines
5.7 KiB
PHP
168 lines
5.7 KiB
PHP
<?php
|
|
|
|
namespace App\Console\Commands;
|
|
|
|
use App\Support\ApiDoc\ResourceFieldDescriber;
|
|
use Illuminate\Console\Command;
|
|
use Symfony\Component\Finder\Finder;
|
|
|
|
/**
|
|
* 기존 API 문서의 응답 필드 설명 TODO 를 리소스 계약 사전 설명으로 소급 채웁니다.
|
|
*
|
|
* 파라미터 TODO 는 `api:docgen-backfill-params` 가 채우지만, 응답 필드 TODO 는 전용
|
|
* 백필 커맨드가 없어 재생성(`api:docgen`)으로만 갱신되었습니다. 실측 서버 없이
|
|
* 재생성하면 이미 실측된 응답 예시값이 퇴행하므로, 이 커맨드는 재생성 대신 응답 필드
|
|
* 표의 `<!-- TODO: 설명 -->` 셀만 in-place 치환합니다.
|
|
*
|
|
* 채움 규칙 SSoT 는 ApiDocScaffolder::responseFieldTable 과 동일한 ResourceFieldDescriber
|
|
* 입니다 — 이후 정상 재생성(실측 서버 가동 시)도 같은 설명을 산출하므로 멱등합니다.
|
|
*
|
|
* 도메인 특이 필드(ResourceFieldDescriber 가 null 반환)는 TODO 를 그대로 둡니다.
|
|
* 응답 필드 표만 대상으로 하며, 파라미터 표(`<!-- TODO: 용도 -->`)는 건드리지 않습니다.
|
|
*/
|
|
class ApiDocBackfillFieldsCommand extends Command
|
|
{
|
|
/**
|
|
* @var string 커맨드 시그니처
|
|
*/
|
|
protected $signature = 'api:docgen-backfill-fields
|
|
{--dry-run : 치환하지 않고 채울 건수만 리포트}';
|
|
|
|
/**
|
|
* @var string 커맨드 설명
|
|
*/
|
|
protected $description = '기존 API 문서의 응답 필드 설명 TODO 를 리소스 계약 사전 설명으로 소급 채웁니다';
|
|
|
|
/**
|
|
* @var string 응답 필드 표를 식별하는 고유 헤더
|
|
*/
|
|
private const FIELD_TABLE_HEADER = '| 필드 | 타입 | 실측 예시값 | 용도/설명 |';
|
|
|
|
/**
|
|
* @var string 응답 필드 설명 TODO 마커
|
|
*/
|
|
private const TODO_MARKER = '<!-- TODO: 설명 -->';
|
|
|
|
/**
|
|
* 커맨드를 실행합니다.
|
|
*
|
|
* @return int 종료 코드
|
|
*/
|
|
public function handle(): int
|
|
{
|
|
$describer = new ResourceFieldDescriber;
|
|
$dryRun = (bool) $this->option('dry-run');
|
|
|
|
$filled = 0;
|
|
$skipped = 0;
|
|
$filesChanged = 0;
|
|
|
|
foreach ($this->apiDocFiles() as $file) {
|
|
$content = file_get_contents($file);
|
|
$result = $this->backfillFile($content, $describer, $filled, $skipped);
|
|
|
|
if ($result !== $content) {
|
|
$filesChanged++;
|
|
if (! $dryRun) {
|
|
file_put_contents($file, $result);
|
|
}
|
|
}
|
|
}
|
|
|
|
$prefix = $dryRun ? '[dry-run] ' : '';
|
|
$this->info("{$prefix}응답 필드 설명 채움: {$filled}건, TODO 유지(도메인 특이): {$skipped}건, 변경 파일: {$filesChanged}개");
|
|
|
|
return self::SUCCESS;
|
|
}
|
|
|
|
/**
|
|
* 대상 API 문서 파일 경로를 수집합니다.
|
|
*
|
|
* @return array<int, string> 파일 경로 목록
|
|
*/
|
|
private function apiDocFiles(): array
|
|
{
|
|
$dirs = array_filter([
|
|
base_path('docs/backend/api'),
|
|
...glob(base_path('modules/_bundled/*/docs/api')),
|
|
...glob(base_path('plugins/_bundled/*/docs/api')),
|
|
], 'is_dir');
|
|
|
|
if ($dirs === []) {
|
|
return [];
|
|
}
|
|
|
|
$files = [];
|
|
foreach (Finder::create()->files()->in($dirs)->name('*.md') as $f) {
|
|
$files[] = $f->getRealPath();
|
|
}
|
|
|
|
return $files;
|
|
}
|
|
|
|
/**
|
|
* 단일 문서의 응답 필드 표 TODO 를 채웁니다.
|
|
*
|
|
* 응답 필드 표 헤더 이후 표 행만 대상으로 하며, `<!-- TODO: 설명 -->` 셀을
|
|
* ResourceFieldDescriber 결과로 치환합니다. 설명이 null(도메인 특이)이면 유지합니다.
|
|
* 파라미터 표는 헤더가 달라 진입하지 않으므로 건드리지 않습니다.
|
|
*
|
|
* @param string $content 문서 내용
|
|
* @param ResourceFieldDescriber $describer 설명기
|
|
* @param int $filled 채운 건수 (참조 누적)
|
|
* @param int $skipped 유지 건수 (참조 누적)
|
|
* @return string 치환된 문서 내용
|
|
*/
|
|
private function backfillFile(string $content, ResourceFieldDescriber $describer, int &$filled, int &$skipped): string
|
|
{
|
|
$lines = explode("\n", $content);
|
|
$inFieldTable = false;
|
|
|
|
foreach ($lines as $i => $line) {
|
|
// 응답 필드 표 헤더 진입 감지
|
|
if (str_contains($line, self::FIELD_TABLE_HEADER)) {
|
|
$inFieldTable = true;
|
|
|
|
continue;
|
|
}
|
|
|
|
// 표 구분선(| --- | ...) 은 건너뜀
|
|
if ($inFieldTable && preg_match('/^\|\s*-+\s*\|/', $line)) {
|
|
continue;
|
|
}
|
|
|
|
// 표가 아닌 라인(빈 줄 또는 | 로 시작하지 않음) → 표 종료
|
|
if ($inFieldTable && ! str_starts_with(ltrim($line), '|')) {
|
|
$inFieldTable = false;
|
|
|
|
continue;
|
|
}
|
|
|
|
if (! $inFieldTable || ! str_contains($line, self::TODO_MARKER)) {
|
|
continue;
|
|
}
|
|
|
|
// 응답 필드 행 파싱: | name | type | `sample` | <!-- TODO: 설명 --> |
|
|
$cells = array_map('trim', explode('|', trim($line, '| ')));
|
|
if (count($cells) < 4) {
|
|
continue;
|
|
}
|
|
|
|
[$name, $type] = [$cells[0], $cells[1]];
|
|
$desc = $describer->describe($name, $type);
|
|
|
|
if ($desc === null) {
|
|
$skipped++;
|
|
|
|
continue;
|
|
}
|
|
|
|
$safe = str_replace(['|', "\n", "\r"], ['\\|', ' ', ''], $desc);
|
|
$lines[$i] = str_replace(self::TODO_MARKER, $safe, $line);
|
|
$filled++;
|
|
}
|
|
|
|
return implode("\n", $lines);
|
|
}
|
|
}
|