| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417 |
- <?php
- declare(strict_types=1);
- namespace Webman\Validation;
- use Closure;
- use InvalidArgumentException;
- use ReflectionFunction;
- use ReflectionFunctionAbstract;
- use ReflectionMethod;
- use ReflectionNamedType;
- use ReflectionParameter;
- use ReflectionUnionType;
- use Webman\Http\Request;
- use Webman\Validation\Annotation\Param;
- use Webman\Validation\Annotation\Validate;
- final class Middleware
- {
- private static array $metadataCache = [];
- public function process(Request $request, callable $handler)
- {
- $metadata = $this->resolveMetadata($request);
- if ($metadata === null || !$metadata['has']) {
- return $handler($request);
- }
- $defaultData = $this->getRequestData($request);
- $this->handleMethodValidation($request, $metadata['methods'], $defaultData);
- $this->handleParamValidation($request, $metadata['params'], $defaultData);
- return $handler($request);
- }
- private function resolveMetadata(Request $request): ?array
- {
- $controller = $request->controller ?: '';
- $action = $request->action ?: '';
- if ($controller !== '' && $action !== '' && class_exists($controller)) {
- return $this->getMethodMetadata($controller, $action);
- }
- return $this->getCallableMetadata($request);
- }
- private function handleMethodValidation(Request $request, array $methods, array $defaultData): void
- {
- if ($methods === []) {
- return;
- }
- foreach ($methods as $config) {
- $data = $this->resolveRequestData($request, $config->in, $defaultData);
- $this->validateMethod($config, $data);
- }
- }
- private function handleParamValidation(Request $request, array $params, array $defaultData): void
- {
- if ($params === []) {
- return;
- }
- $allData = [];
- $allRules = [];
- $allMessages = [];
- $allAttributes = [];
- foreach ($params as $item) {
- $name = $item['name'];
- /** @var \Webman\Validation\Annotation\Param $config */
- $config = $item['config'];
- $dataForParam = $this->resolveRequestData($request, $config->in, $defaultData);
- $value = $dataForParam[$name] ?? null;
- if ($value === null && $item['hasDefault']) {
- $value = $item['default'];
- }
- $allData[$name] = $value;
- $allRules[$name] = $config->rules;
- // 处理 messages,确保 key 带有字段前缀,避免冲突
- foreach ($config->messages as $key => $message) {
- if (!str_contains($key, '.')) {
- // 没有点号的 key 自动添加字段名前缀
- $key = $name . '.' . $key;
- }
- $allMessages[$key] = $message;
- }
- if ($config->attribute !== '') {
- $allAttributes[$name] = $config->attribute;
- }
- }
- Validator::make($allData, $allRules, $allMessages, $allAttributes)->validate();
- }
- private function validateMethod(Validate $config, array $data): void
- {
- if ($config->validator !== null) {
- if ($config->rules !== []) {
- throw new InvalidArgumentException('Validate cannot set both validator and rules.');
- }
- if (!class_exists($config->validator)) {
- throw new InvalidArgumentException("Validator class not found: {$config->validator}");
- }
- if (!is_subclass_of($config->validator, \Webman\Validation\Validator::class)) {
- throw new InvalidArgumentException("Validator must extend Webman\\Validation\\Validator (or support\\validation\\Validator): {$config->validator}");
- }
- $validator = $config->validator::make($data);
- if ($config->scene !== null) {
- $validator = $validator->withScene($config->scene);
- }
- $validator->validate();
- return;
- }
- if ($config->rules === []) {
- return;
- }
- Validator::make($data, $config->rules, $config->messages, $config->attributes)->validate();
- }
- private function getRequestData(Request $request): array
- {
- $routeParams = $request->route ? $request->route->param() : [];
- if (!is_array($routeParams)) {
- $routeParams = [];
- }
- return array_merge($request->all() ?: [], $routeParams);
- }
- private function resolveRequestData(Request $request, string|array|null $in, array $defaultData): array
- {
- if ($in === null || $in === []) {
- return $defaultData;
- }
- $parts = is_array($in) ? $in : [$in];
- $data = [];
- foreach ($parts as $part) {
- $data = array_merge($data, $this->getRequestPartData($request, $part));
- }
- return $data;
- }
- private function getRequestPartData(Request $request, mixed $part): array
- {
- if (!is_string($part) || $part === '') {
- throw new InvalidArgumentException('Validate/Param in must be a non-empty string or string array.');
- }
- return match ($part) {
- 'query' => $request->get() ?: [],
- 'body' => $request->post() ?: [],
- 'path' => $this->getPathParams($request),
- default => throw new InvalidArgumentException("Unsupported in value: {$part}. Only query|body|path are supported."),
- };
- }
- private function getPathParams(Request $request): array
- {
- $routeParams = $request->route ? $request->route->param() : [];
- return is_array($routeParams) ? $routeParams : [];
- }
- private function getMethodMetadata(string $controller, string $action): ?array
- {
- $key = $controller . '::' . $action;
- if (isset(self::$metadataCache[$key])) {
- return self::$metadataCache[$key];
- }
- if (!method_exists($controller, $action)) {
- return self::$metadataCache[$key] = null;
- }
- $reflection = new ReflectionMethod($controller, $action);
- return self::$metadataCache[$key] = $this->buildMetadataFromReflection($reflection, true);
- }
- /**
- * Resolve metadata for closure / named-function route handlers.
- */
- private function getCallableMetadata(Request $request): ?array
- {
- $route = $request->route;
- if (!$route || !method_exists($route, 'getCallback')) {
- return null;
- }
- $callback = $route->getCallback();
- // Only handle closures and named function strings.
- if (!$callback instanceof Closure && !is_string($callback)) {
- return null;
- }
- if (is_string($callback) && !function_exists($callback)) {
- return null;
- }
- $cacheKey = is_string($callback)
- ? 'func::' . $callback
- : 'callable::' . $route->getPath();
- if (isset(self::$metadataCache[$cacheKey])) {
- return self::$metadataCache[$cacheKey];
- }
- $reflection = new ReflectionFunction($callback);
- // Named functions support function-level #[Validate]; closures do not.
- $supportsMethodAttributes = is_string($callback);
- return self::$metadataCache[$cacheKey] = $this->buildMetadataFromReflection(
- $reflection,
- $supportsMethodAttributes
- );
- }
- /**
- * Build validation metadata from a ReflectionMethod or ReflectionFunction.
- */
- private function buildMetadataFromReflection(
- ReflectionFunctionAbstract $reflection,
- bool $supportsMethodAttributes
- ): array {
- $methods = [];
- if ($supportsMethodAttributes) {
- foreach ($reflection->getAttributes(Validate::class, \ReflectionAttribute::IS_INSTANCEOF) as $attribute) {
- $methods[] = $attribute->newInstance();
- }
- }
- $parameters = $reflection->getParameters();
- $hasAnyParamAttribute = false;
- foreach ($parameters as $parameter) {
- if ($parameter->getAttributes(Param::class, \ReflectionAttribute::IS_INSTANCEOF) !== []) {
- $hasAnyParamAttribute = true;
- break;
- }
- }
- $inferWhenAnnotationsPresent = $methods !== [] || $hasAnyParamAttribute;
- $params = [];
- foreach ($parameters as $parameter) {
- $paramConfig = $this->resolveParamConfig($parameter, $inferWhenAnnotationsPresent);
- if ($paramConfig === null) {
- continue;
- }
- $hasDefault = $parameter->isDefaultValueAvailable();
- $params[] = [
- 'name' => $parameter->getName(),
- 'config' => $paramConfig,
- 'hasDefault' => $hasDefault,
- 'default' => $hasDefault ? $parameter->getDefaultValue() : null,
- ];
- }
- return [
- 'has' => $methods !== [] || $params !== [],
- 'methods' => $methods,
- 'params' => $params,
- ];
- }
- private function resolveParamConfig(ReflectionParameter $parameter, bool $inferWhenAnnotationsPresent): ?Param
- {
- $attributes = $parameter->getAttributes(Param::class, \ReflectionAttribute::IS_INSTANCEOF);
- if ($attributes !== []) {
- /** @var Param $config */
- $config = $attributes[0]->newInstance();
- // Auto-complete rules based on parameter signature.
- $completedRules = $this->completeRulesFromParameter($parameter, $config->rules);
- if ($completedRules !== $config->rules) {
- return new Param(
- rules: $completedRules,
- messages: $config->messages,
- attribute: $config->attribute,
- in: $config->in
- );
- }
- return $config;
- }
- if (!$inferWhenAnnotationsPresent) {
- return null;
- }
- if ($this->shouldSkipParameter($parameter)) {
- return null;
- }
- $rules = $this->inferRulesFromParameter($parameter);
- return new Param(rules: $rules);
- }
- private function shouldSkipParameter(ReflectionParameter $parameter): bool
- {
- $type = $parameter->getType();
- if (!$type instanceof ReflectionNamedType || $type->isBuiltin()) {
- return false;
- }
- $name = $type->getName();
- if ($name === '') {
- return true;
- }
- // Skip framework request injection.
- if (is_a($name, Request::class, true)) {
- return true;
- }
- // Skip other class-typed parameters by default (services/DTOs/etc).
- return true;
- }
- private function inferRulesFromParameter(ReflectionParameter $parameter): string|array
- {
- $rules = [];
- $type = $parameter->getType();
- $isNullable = $type instanceof ReflectionNamedType && $type->allowsNull();
- // Required when: no default value AND not nullable.
- if (!$parameter->isDefaultValueAvailable() && !$isNullable) {
- $rules[] = 'required';
- }
- if ($type instanceof ReflectionUnionType) {
- // Union types are not inferred by default (developer can explicitly use #[Param]).
- return implode('|', $rules);
- }
- if ($type instanceof ReflectionNamedType && $type->isBuiltin()) {
- $mapped = $this->mapBuiltinTypeToRule($type->getName());
- if ($mapped !== '') {
- $rules[] = $mapped;
- }
- if ($isNullable) {
- $rules[] = 'nullable';
- }
- }
- return implode('|', $rules);
- }
- private function completeRulesFromParameter(ReflectionParameter $parameter, string|array $existingRules): string|array
- {
- $isArray = is_array($existingRules);
- $rulesList = $isArray
- ? $existingRules
- : ($existingRules !== '' ? explode('|', $existingRules) : []);
- $type = $parameter->getType();
- $isNullable = $type instanceof ReflectionNamedType && $type->allowsNull();
- // Build rule name set for O(1) lookups instead of iterating per check.
- $ruleNames = [];
- foreach ($rulesList as $rule) {
- $ruleNames[explode(':', $rule, 2)[0]] = true;
- }
- // Auto-complete 'required' if: no default value, not nullable, and not already present.
- if (!$parameter->isDefaultValueAvailable() && !$isNullable && !isset($ruleNames['required'])) {
- array_unshift($rulesList, 'required');
- $ruleNames['required'] = true;
- }
- // Auto-complete type rule if: has builtin type and no type rule present.
- if ($type instanceof ReflectionNamedType && $type->isBuiltin()) {
- $mappedRule = $this->mapBuiltinTypeToRule($type->getName());
- if ($mappedRule !== '' && !isset($ruleNames[$mappedRule])) {
- // Insert type rule after 'required' if present, otherwise at the beginning.
- $requiredIndex = array_search('required', $rulesList, true);
- if ($requiredIndex !== false) {
- array_splice($rulesList, $requiredIndex + 1, 0, $mappedRule);
- } else {
- array_unshift($rulesList, $mappedRule);
- }
- $ruleNames[$mappedRule] = true;
- }
- // Auto-complete 'nullable' if: type is nullable and not already present.
- if ($isNullable && !isset($ruleNames['nullable'])) {
- $rulesList[] = 'nullable';
- }
- }
- return $isArray ? $rulesList : implode('|', $rulesList);
- }
- private function mapBuiltinTypeToRule(string $type): string
- {
- return match ($type) {
- 'string' => 'string',
- 'int' => 'integer',
- 'float' => 'numeric',
- 'bool' => 'boolean',
- 'array' => 'array',
- default => '',
- };
- }
- }
|