Middleware.php 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417
  1. <?php
  2. declare(strict_types=1);
  3. namespace Webman\Validation;
  4. use Closure;
  5. use InvalidArgumentException;
  6. use ReflectionFunction;
  7. use ReflectionFunctionAbstract;
  8. use ReflectionMethod;
  9. use ReflectionNamedType;
  10. use ReflectionParameter;
  11. use ReflectionUnionType;
  12. use Webman\Http\Request;
  13. use Webman\Validation\Annotation\Param;
  14. use Webman\Validation\Annotation\Validate;
  15. final class Middleware
  16. {
  17. private static array $metadataCache = [];
  18. public function process(Request $request, callable $handler)
  19. {
  20. $metadata = $this->resolveMetadata($request);
  21. if ($metadata === null || !$metadata['has']) {
  22. return $handler($request);
  23. }
  24. $defaultData = $this->getRequestData($request);
  25. $this->handleMethodValidation($request, $metadata['methods'], $defaultData);
  26. $this->handleParamValidation($request, $metadata['params'], $defaultData);
  27. return $handler($request);
  28. }
  29. private function resolveMetadata(Request $request): ?array
  30. {
  31. $controller = $request->controller ?: '';
  32. $action = $request->action ?: '';
  33. if ($controller !== '' && $action !== '' && class_exists($controller)) {
  34. return $this->getMethodMetadata($controller, $action);
  35. }
  36. return $this->getCallableMetadata($request);
  37. }
  38. private function handleMethodValidation(Request $request, array $methods, array $defaultData): void
  39. {
  40. if ($methods === []) {
  41. return;
  42. }
  43. foreach ($methods as $config) {
  44. $data = $this->resolveRequestData($request, $config->in, $defaultData);
  45. $this->validateMethod($config, $data);
  46. }
  47. }
  48. private function handleParamValidation(Request $request, array $params, array $defaultData): void
  49. {
  50. if ($params === []) {
  51. return;
  52. }
  53. $allData = [];
  54. $allRules = [];
  55. $allMessages = [];
  56. $allAttributes = [];
  57. foreach ($params as $item) {
  58. $name = $item['name'];
  59. /** @var \Webman\Validation\Annotation\Param $config */
  60. $config = $item['config'];
  61. $dataForParam = $this->resolveRequestData($request, $config->in, $defaultData);
  62. $value = $dataForParam[$name] ?? null;
  63. if ($value === null && $item['hasDefault']) {
  64. $value = $item['default'];
  65. }
  66. $allData[$name] = $value;
  67. $allRules[$name] = $config->rules;
  68. // 处理 messages,确保 key 带有字段前缀,避免冲突
  69. foreach ($config->messages as $key => $message) {
  70. if (!str_contains($key, '.')) {
  71. // 没有点号的 key 自动添加字段名前缀
  72. $key = $name . '.' . $key;
  73. }
  74. $allMessages[$key] = $message;
  75. }
  76. if ($config->attribute !== '') {
  77. $allAttributes[$name] = $config->attribute;
  78. }
  79. }
  80. Validator::make($allData, $allRules, $allMessages, $allAttributes)->validate();
  81. }
  82. private function validateMethod(Validate $config, array $data): void
  83. {
  84. if ($config->validator !== null) {
  85. if ($config->rules !== []) {
  86. throw new InvalidArgumentException('Validate cannot set both validator and rules.');
  87. }
  88. if (!class_exists($config->validator)) {
  89. throw new InvalidArgumentException("Validator class not found: {$config->validator}");
  90. }
  91. if (!is_subclass_of($config->validator, \Webman\Validation\Validator::class)) {
  92. throw new InvalidArgumentException("Validator must extend Webman\\Validation\\Validator (or support\\validation\\Validator): {$config->validator}");
  93. }
  94. $validator = $config->validator::make($data);
  95. if ($config->scene !== null) {
  96. $validator = $validator->withScene($config->scene);
  97. }
  98. $validator->validate();
  99. return;
  100. }
  101. if ($config->rules === []) {
  102. return;
  103. }
  104. Validator::make($data, $config->rules, $config->messages, $config->attributes)->validate();
  105. }
  106. private function getRequestData(Request $request): array
  107. {
  108. $routeParams = $request->route ? $request->route->param() : [];
  109. if (!is_array($routeParams)) {
  110. $routeParams = [];
  111. }
  112. return array_merge($request->all() ?: [], $routeParams);
  113. }
  114. private function resolveRequestData(Request $request, string|array|null $in, array $defaultData): array
  115. {
  116. if ($in === null || $in === []) {
  117. return $defaultData;
  118. }
  119. $parts = is_array($in) ? $in : [$in];
  120. $data = [];
  121. foreach ($parts as $part) {
  122. $data = array_merge($data, $this->getRequestPartData($request, $part));
  123. }
  124. return $data;
  125. }
  126. private function getRequestPartData(Request $request, mixed $part): array
  127. {
  128. if (!is_string($part) || $part === '') {
  129. throw new InvalidArgumentException('Validate/Param in must be a non-empty string or string array.');
  130. }
  131. return match ($part) {
  132. 'query' => $request->get() ?: [],
  133. 'body' => $request->post() ?: [],
  134. 'path' => $this->getPathParams($request),
  135. default => throw new InvalidArgumentException("Unsupported in value: {$part}. Only query|body|path are supported."),
  136. };
  137. }
  138. private function getPathParams(Request $request): array
  139. {
  140. $routeParams = $request->route ? $request->route->param() : [];
  141. return is_array($routeParams) ? $routeParams : [];
  142. }
  143. private function getMethodMetadata(string $controller, string $action): ?array
  144. {
  145. $key = $controller . '::' . $action;
  146. if (isset(self::$metadataCache[$key])) {
  147. return self::$metadataCache[$key];
  148. }
  149. if (!method_exists($controller, $action)) {
  150. return self::$metadataCache[$key] = null;
  151. }
  152. $reflection = new ReflectionMethod($controller, $action);
  153. return self::$metadataCache[$key] = $this->buildMetadataFromReflection($reflection, true);
  154. }
  155. /**
  156. * Resolve metadata for closure / named-function route handlers.
  157. */
  158. private function getCallableMetadata(Request $request): ?array
  159. {
  160. $route = $request->route;
  161. if (!$route || !method_exists($route, 'getCallback')) {
  162. return null;
  163. }
  164. $callback = $route->getCallback();
  165. // Only handle closures and named function strings.
  166. if (!$callback instanceof Closure && !is_string($callback)) {
  167. return null;
  168. }
  169. if (is_string($callback) && !function_exists($callback)) {
  170. return null;
  171. }
  172. $cacheKey = is_string($callback)
  173. ? 'func::' . $callback
  174. : 'callable::' . $route->getPath();
  175. if (isset(self::$metadataCache[$cacheKey])) {
  176. return self::$metadataCache[$cacheKey];
  177. }
  178. $reflection = new ReflectionFunction($callback);
  179. // Named functions support function-level #[Validate]; closures do not.
  180. $supportsMethodAttributes = is_string($callback);
  181. return self::$metadataCache[$cacheKey] = $this->buildMetadataFromReflection(
  182. $reflection,
  183. $supportsMethodAttributes
  184. );
  185. }
  186. /**
  187. * Build validation metadata from a ReflectionMethod or ReflectionFunction.
  188. */
  189. private function buildMetadataFromReflection(
  190. ReflectionFunctionAbstract $reflection,
  191. bool $supportsMethodAttributes
  192. ): array {
  193. $methods = [];
  194. if ($supportsMethodAttributes) {
  195. foreach ($reflection->getAttributes(Validate::class, \ReflectionAttribute::IS_INSTANCEOF) as $attribute) {
  196. $methods[] = $attribute->newInstance();
  197. }
  198. }
  199. $parameters = $reflection->getParameters();
  200. $hasAnyParamAttribute = false;
  201. foreach ($parameters as $parameter) {
  202. if ($parameter->getAttributes(Param::class, \ReflectionAttribute::IS_INSTANCEOF) !== []) {
  203. $hasAnyParamAttribute = true;
  204. break;
  205. }
  206. }
  207. $inferWhenAnnotationsPresent = $methods !== [] || $hasAnyParamAttribute;
  208. $params = [];
  209. foreach ($parameters as $parameter) {
  210. $paramConfig = $this->resolveParamConfig($parameter, $inferWhenAnnotationsPresent);
  211. if ($paramConfig === null) {
  212. continue;
  213. }
  214. $hasDefault = $parameter->isDefaultValueAvailable();
  215. $params[] = [
  216. 'name' => $parameter->getName(),
  217. 'config' => $paramConfig,
  218. 'hasDefault' => $hasDefault,
  219. 'default' => $hasDefault ? $parameter->getDefaultValue() : null,
  220. ];
  221. }
  222. return [
  223. 'has' => $methods !== [] || $params !== [],
  224. 'methods' => $methods,
  225. 'params' => $params,
  226. ];
  227. }
  228. private function resolveParamConfig(ReflectionParameter $parameter, bool $inferWhenAnnotationsPresent): ?Param
  229. {
  230. $attributes = $parameter->getAttributes(Param::class, \ReflectionAttribute::IS_INSTANCEOF);
  231. if ($attributes !== []) {
  232. /** @var Param $config */
  233. $config = $attributes[0]->newInstance();
  234. // Auto-complete rules based on parameter signature.
  235. $completedRules = $this->completeRulesFromParameter($parameter, $config->rules);
  236. if ($completedRules !== $config->rules) {
  237. return new Param(
  238. rules: $completedRules,
  239. messages: $config->messages,
  240. attribute: $config->attribute,
  241. in: $config->in
  242. );
  243. }
  244. return $config;
  245. }
  246. if (!$inferWhenAnnotationsPresent) {
  247. return null;
  248. }
  249. if ($this->shouldSkipParameter($parameter)) {
  250. return null;
  251. }
  252. $rules = $this->inferRulesFromParameter($parameter);
  253. return new Param(rules: $rules);
  254. }
  255. private function shouldSkipParameter(ReflectionParameter $parameter): bool
  256. {
  257. $type = $parameter->getType();
  258. if (!$type instanceof ReflectionNamedType || $type->isBuiltin()) {
  259. return false;
  260. }
  261. $name = $type->getName();
  262. if ($name === '') {
  263. return true;
  264. }
  265. // Skip framework request injection.
  266. if (is_a($name, Request::class, true)) {
  267. return true;
  268. }
  269. // Skip other class-typed parameters by default (services/DTOs/etc).
  270. return true;
  271. }
  272. private function inferRulesFromParameter(ReflectionParameter $parameter): string|array
  273. {
  274. $rules = [];
  275. $type = $parameter->getType();
  276. $isNullable = $type instanceof ReflectionNamedType && $type->allowsNull();
  277. // Required when: no default value AND not nullable.
  278. if (!$parameter->isDefaultValueAvailable() && !$isNullable) {
  279. $rules[] = 'required';
  280. }
  281. if ($type instanceof ReflectionUnionType) {
  282. // Union types are not inferred by default (developer can explicitly use #[Param]).
  283. return implode('|', $rules);
  284. }
  285. if ($type instanceof ReflectionNamedType && $type->isBuiltin()) {
  286. $mapped = $this->mapBuiltinTypeToRule($type->getName());
  287. if ($mapped !== '') {
  288. $rules[] = $mapped;
  289. }
  290. if ($isNullable) {
  291. $rules[] = 'nullable';
  292. }
  293. }
  294. return implode('|', $rules);
  295. }
  296. private function completeRulesFromParameter(ReflectionParameter $parameter, string|array $existingRules): string|array
  297. {
  298. $isArray = is_array($existingRules);
  299. $rulesList = $isArray
  300. ? $existingRules
  301. : ($existingRules !== '' ? explode('|', $existingRules) : []);
  302. $type = $parameter->getType();
  303. $isNullable = $type instanceof ReflectionNamedType && $type->allowsNull();
  304. // Build rule name set for O(1) lookups instead of iterating per check.
  305. $ruleNames = [];
  306. foreach ($rulesList as $rule) {
  307. $ruleNames[explode(':', $rule, 2)[0]] = true;
  308. }
  309. // Auto-complete 'required' if: no default value, not nullable, and not already present.
  310. if (!$parameter->isDefaultValueAvailable() && !$isNullable && !isset($ruleNames['required'])) {
  311. array_unshift($rulesList, 'required');
  312. $ruleNames['required'] = true;
  313. }
  314. // Auto-complete type rule if: has builtin type and no type rule present.
  315. if ($type instanceof ReflectionNamedType && $type->isBuiltin()) {
  316. $mappedRule = $this->mapBuiltinTypeToRule($type->getName());
  317. if ($mappedRule !== '' && !isset($ruleNames[$mappedRule])) {
  318. // Insert type rule after 'required' if present, otherwise at the beginning.
  319. $requiredIndex = array_search('required', $rulesList, true);
  320. if ($requiredIndex !== false) {
  321. array_splice($rulesList, $requiredIndex + 1, 0, $mappedRule);
  322. } else {
  323. array_unshift($rulesList, $mappedRule);
  324. }
  325. $ruleNames[$mappedRule] = true;
  326. }
  327. // Auto-complete 'nullable' if: type is nullable and not already present.
  328. if ($isNullable && !isset($ruleNames['nullable'])) {
  329. $rulesList[] = 'nullable';
  330. }
  331. }
  332. return $isArray ? $rulesList : implode('|', $rulesList);
  333. }
  334. private function mapBuiltinTypeToRule(string $type): string
  335. {
  336. return match ($type) {
  337. 'string' => 'string',
  338. 'int' => 'integer',
  339. 'float' => 'numeric',
  340. 'bool' => 'boolean',
  341. 'array' => 'array',
  342. default => '',
  343. };
  344. }
  345. }