>, hook_filters: array} */ 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|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 $requestClass FormRequest FQCN * @return array 검증 규칙 배열 */ 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 $rules 검증 규칙 배열 * @return array> 파라미터 메타데이터 목록 */ 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 $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 $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 $requestClass FormRequest FQCN * @return array 훅 필터 이름 목록 */ 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 []; } } }