코드에만 존재하던 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 동기
201 lines
6.2 KiB
PHP
201 lines
6.2 KiB
PHP
<?php
|
|
|
|
namespace App\Support\ApiDoc;
|
|
|
|
use Illuminate\Foundation\Http\FormRequest;
|
|
use ReflectionClass;
|
|
use ReflectionMethod;
|
|
use ReflectionNamedType;
|
|
|
|
/**
|
|
* FormRequest 정적 분석기
|
|
*
|
|
* 컨트롤러 메서드 시그니처에서 FormRequest 를 찾아 rules() 를 리플렉션으로
|
|
* 읽고, 요청 파라미터의 타입·필수 여부·허용값을 문서용 메타데이터로 변환합니다.
|
|
*/
|
|
class FormRequestIntrospector
|
|
{
|
|
/**
|
|
* 컨트롤러 메서드의 첫 FormRequest 파라미터에서 rules 메타데이터를 추출합니다.
|
|
*
|
|
* @param string|null $controller 컨트롤러 FQCN
|
|
* @param string|null $method 메서드명
|
|
* @return array{request_class: string|null, params: array<int, array<string, mixed>>, hook_filters: array<int, string>}
|
|
*/
|
|
public function introspect(?string $controller, ?string $method): array
|
|
{
|
|
$empty = ['request_class' => null, 'params' => [], 'hook_filters' => []];
|
|
|
|
if (! $controller || ! $method || ! class_exists($controller)) {
|
|
return $empty;
|
|
}
|
|
|
|
try {
|
|
$ref = new ReflectionMethod($controller, $method);
|
|
} catch (\ReflectionException) {
|
|
return $empty;
|
|
}
|
|
|
|
$requestClass = $this->findFormRequestParam($ref);
|
|
|
|
if (! $requestClass) {
|
|
return $empty;
|
|
}
|
|
|
|
$rules = $this->extractRules($requestClass);
|
|
|
|
return [
|
|
'request_class' => $requestClass,
|
|
'params' => $this->rulesToParams($rules),
|
|
'hook_filters' => $this->extractHookFilters($requestClass),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 메서드 파라미터 중 FormRequest 하위 클래스를 찾습니다.
|
|
*
|
|
* @param ReflectionMethod $ref 메서드 리플렉션
|
|
* @return class-string<FormRequest>|null FormRequest FQCN
|
|
*/
|
|
private function findFormRequestParam(ReflectionMethod $ref): ?string
|
|
{
|
|
foreach ($ref->getParameters() as $param) {
|
|
$type = $param->getType();
|
|
|
|
if (! $type instanceof ReflectionNamedType || $type->isBuiltin()) {
|
|
continue;
|
|
}
|
|
|
|
$class = $type->getName();
|
|
|
|
if (is_subclass_of($class, FormRequest::class)) {
|
|
return $class;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* FormRequest 인스턴스를 만들어 rules() 를 호출합니다.
|
|
*
|
|
* @param class-string<FormRequest> $requestClass FormRequest FQCN
|
|
* @return array<string, mixed> 검증 규칙 배열
|
|
*/
|
|
private function extractRules(string $requestClass): array
|
|
{
|
|
try {
|
|
$instance = new $requestClass;
|
|
|
|
if (! method_exists($instance, 'rules')) {
|
|
return [];
|
|
}
|
|
|
|
$rules = $instance->rules();
|
|
|
|
return is_array($rules) ? $rules : [];
|
|
} catch (\Throwable) {
|
|
// rules() 가 컨테이너/route 의존이면 정적 호출 실패 — 파라미터 표는 비운다.
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 검증 규칙 배열을 문서용 파라미터 메타데이터로 변환합니다.
|
|
*
|
|
* @param array<string, mixed> $rules 검증 규칙 배열
|
|
* @return array<int, array<string, mixed>> 파라미터 메타데이터 목록
|
|
*/
|
|
private function rulesToParams(array $rules): array
|
|
{
|
|
$params = [];
|
|
|
|
foreach ($rules as $field => $rule) {
|
|
// 중첩 필드(items.*.id)는 상위만 대표로 노출
|
|
if (str_contains((string) $field, '.')) {
|
|
continue;
|
|
}
|
|
|
|
$tokens = is_array($rule) ? $rule : explode('|', (string) $rule);
|
|
$tokens = array_map(fn ($t) => is_string($t) ? $t : '', $tokens);
|
|
|
|
$params[] = [
|
|
'name' => $field,
|
|
'type' => $this->inferType($tokens),
|
|
'required' => in_array('required', $tokens, true),
|
|
'allowed' => $this->inferAllowed($tokens),
|
|
];
|
|
}
|
|
|
|
return $params;
|
|
}
|
|
|
|
/**
|
|
* 규칙 토큰에서 파라미터 타입을 유추합니다.
|
|
*
|
|
* @param array<int, string> $tokens 규칙 토큰 목록
|
|
* @return string 유추된 타입
|
|
*/
|
|
private function inferType(array $tokens): string
|
|
{
|
|
foreach (['integer', 'numeric', 'boolean', 'array', 'string', 'date', 'email', 'file', 'image', 'uuid'] as $type) {
|
|
foreach ($tokens as $t) {
|
|
if ($t === $type || str_starts_with($t, $type)) {
|
|
return $type === 'numeric' ? 'number' : $type;
|
|
}
|
|
}
|
|
}
|
|
|
|
return 'string';
|
|
}
|
|
|
|
/**
|
|
* 규칙 토큰에서 허용값(in:, max:, min: 등)을 유추합니다.
|
|
*
|
|
* @param array<int, string> $tokens 규칙 토큰 목록
|
|
* @return string 허용값 설명 (없으면 빈 문자열)
|
|
*/
|
|
private function inferAllowed(array $tokens): string
|
|
{
|
|
$parts = [];
|
|
|
|
foreach ($tokens as $t) {
|
|
if (str_starts_with($t, 'in:')) {
|
|
$parts[] = '`'.str_replace(',', '`, `', substr($t, 3)).'`';
|
|
} elseif (str_starts_with($t, 'max:')) {
|
|
$parts[] = 'max '.substr($t, 4);
|
|
} elseif (str_starts_with($t, 'min:')) {
|
|
$parts[] = 'min '.substr($t, 4);
|
|
} elseif (str_starts_with($t, 'between:')) {
|
|
$parts[] = 'between '.substr($t, 8);
|
|
}
|
|
}
|
|
|
|
return implode(', ', $parts);
|
|
}
|
|
|
|
/**
|
|
* FormRequest 소스에서 HookManager::applyFilters 훅 이름을 추출합니다.
|
|
*
|
|
* @param class-string<FormRequest> $requestClass FormRequest FQCN
|
|
* @return array<int, string> 훅 필터 이름 목록
|
|
*/
|
|
private function extractHookFilters(string $requestClass): array
|
|
{
|
|
try {
|
|
$file = (new ReflectionClass($requestClass))->getFileName();
|
|
|
|
if (! $file || ! is_readable($file)) {
|
|
return [];
|
|
}
|
|
|
|
$source = file_get_contents($file);
|
|
preg_match_all('/applyFilters\(\s*[\'"]([a-z0-9_.-]+)[\'"]/i', $source, $m);
|
|
|
|
return array_values(array_unique($m[1] ?? []));
|
|
} catch (\Throwable) {
|
|
return [];
|
|
}
|
|
}
|
|
}
|