Files
Gnuboard7/app/Support/ApiDoc/FormRequestIntrospector.php
HeuJung 403154167a fix(core): api:docgen 재생성이 문서를 손실·열화시키는 결함 수정
재생성이 사람이 채운 서술과 이전 실측 결과를 지우고, 코드가 바뀌지 않은
문서까지 매 실행마다 흔들던 결함 5건을 고쳤다.

- 중첩 객체 파라미터가 문서에서 통째로 누락되던 문제: 배열 요소(items.*.id)를
 상위로 대표시키려던 스킵 조건이 점(.) 포함 여부만 봐서, 와일드카드가 없는
 중첩 객체 필드(refund_bank.*, general.*, content.* 등 17개 엔드포인트 수백 개)
 까지 함께 버렸다. 와일드카드가 있을 때만 스킵하도록 좁혔다.
- 사람이 보강한 에러 응답 표가 자동 추론 초안에 덮여 사라지던 문제:
 상태코드 키 단위로 병합한다.
- 실측 실패가 이전에 관측해 둔 응답 예시를 지우던 문제: 실측 성패는 호출 시점
 데이터 유무에 좌우되므로(DELETE /checkout 은 대상이 없으면 404), 실패는
 "새로 관측하지 못했다"는 뜻이지 기존 관측이 무효라는 뜻이 아니다.
- 실측 예시값·응답 예시 JSON 이 DB 상태를 그대로 반영해 비멱등하던 문제:
 값이 아니라 스펙(필드 집합·타입)이 바뀐 경우에만 갱신한다. nullable 이
 null 로 관측된 것은 타입 변경이 아니므로 기존 값을 유지한다.
- 요청 예시 path 파라미터가 실측 성패에 따라 흔들리던 문제: placeholder 로 고정.

결과: 문서 63개 전수 대조에서 손실 0(서술·표 셀·응답 예시), 전 scope 연속
재생성 시 diff 0(멱등).
2026-08-06 13:54:35 +09:00

203 lines
6.4 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)은 상위 필드(items)만 대표로 노출한다.
// 점(`.`) 포함 여부만 보면 와일드카드가 없는 중첩 **객체** 필드
// (refund_bank.bank_code, general.site_name 등)까지 버려져 문서에서 통째로 빠진다.
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 [];
}
}
}