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