Files
Gnuboard7/app/Support/ApiDoc/FormRequestIntrospector.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

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 [];
}
}
}