diff --git a/docs/pages/docs/changelog/index.md b/docs/pages/docs/changelog/index.md index 56bc1aa8a5f453adb210ed496742c1059790fff2..dfe9024463d905eaf64cb896669e79ddccbda9ab 100644 --- a/docs/pages/docs/changelog/index.md +++ b/docs/pages/docs/changelog/index.md @@ -10,6 +10,10 @@ title: Changelog # Changelog +## v0.20.0 + +- Feat: reworked {{docs/features/http/controllers}} parameter resolution handlers + ## v0.19.1 - Fix: some input validators were not cached correctly diff --git a/docs/pages/docs/features/validation/http-controller-parameters/index.md b/docs/pages/docs/features/validation/http-controller-parameters/index.md index db8809381801cdd8fe3827847b89046ddfd0cedd..ab9c1deff5d94c2836440517ac564cbc636d7edb 100644 --- a/docs/pages/docs/features/validation/http-controller-parameters/index.md +++ b/docs/pages/docs/features/validation/http-controller-parameters/index.md @@ -45,8 +45,8 @@ public function handle( ## Error Handling -To handle errors, you can create an optional method marked with the -`#[ValidationErrorsHandler]` attribute. If such a method exists, then will be +To handle errors, you can create an optional method marked in the +`#[OnParameterResolution]` attribute. If such a method exists, then will be be called in case of validation failure. If no such method exists, the controller is going to return a generic @@ -56,8 +56,7 @@ If no such method exists, the controller is going to return a generic `handle` method's arguments are forwarded into the error validation method. It can only use the parameters that are already resolved in the `handle` method -plus an extra argument with validation errors (marked by the -`#[ValidationErrors]`) and request/response pair. +plus an extra argument with validation errors and request/response pair. Adding new arguments to the error handler besides those is going to cause an error. @@ -73,9 +72,10 @@ use App\InputValidatedData\BlogPostForm; use App\InputValidator\BlogPostFormValidator; use Distantmagic\Resonance\Attribute\RespondsToHttp; use Distantmagic\Resonance\Attribute\Singleton; +use Distantmagic\Resonance\Attribute\OnParameterResolution; use Distantmagic\Resonance\Attribute\ValidatedRequest; -use Distantmagic\Resonance\Attribute\ValidationErrors; -use Distantmagic\Resonance\Attribute\ValidationErrorsHandler; +use Distantmagic\Resonance\HttpControllerParameterResolution; +use Distantmagic\Resonance\HttpControllerParameterResolutionStatus; use Distantmagic\Resonance\HttpResponder\HttpController; use Distantmagic\Resonance\HttpResponderInterface; use Distantmagic\Resonance\RequestMethod; @@ -95,6 +95,10 @@ final readonly class BlogPostStore extends HttpController { public function handle( #[ValidatedRequest(BlogPostFormValidator::class)] + #[OnParameterResolution( + status: HttpControllerParameterResolutionStatus::ValidationErrors, + forwardTo: 'handleValidationErrors', + )] BlogPostForm $blogPostForm, ): HttpResponderInterface { /* inser blog post, redirect, etc */ @@ -104,12 +108,10 @@ final readonly class BlogPostStore extends HttpController /** * @param Map<string,Set<string>> $errors */ - #[ValidationErrorsHandler] public function handleValidationErrors( Request $request, Response $response, - #[ValidationErrors] - Map $errors, + HttpControllerParameterResolution $resolution, ): HttpResponderInterface { $response->status(400); diff --git a/docs/pages/tutorials/session-based-authentication/index.md b/docs/pages/tutorials/session-based-authentication/index.md index bc86636af5c4450f597433c6d36dc5b8b56ccdeb..9a97b0f83a32ef1bc08ca6adec3145d9000e9b99 100644 --- a/docs/pages/tutorials/session-based-authentication/index.md +++ b/docs/pages/tutorials/session-based-authentication/index.md @@ -451,7 +451,7 @@ final readonly class LoginValidation extends HttpController UsernamePassword $usernamePassword, #[DoctrineEntityRepository(User::class)] EntityRepository $users, - ): HttpInterceptableInterface { + ): null|HttpInterceptableInterface { /** * @var null|User */ @@ -460,9 +460,9 @@ final readonly class LoginValidation extends HttpController ]); if (!$user || !password_verify($usernamePassword->password, $user->getPasswordHash())) { - return $this->handleValidationErrors($response, new Map([ - 'username' => 'Invalid credentials', - ])); + $response->status(403); + + return; } $this->sessionAuthentication->setAuthenticatedUser( @@ -473,22 +473,6 @@ final readonly class LoginValidation extends HttpController return new InternalRedirect(HttpRouteSymbol::Homepage); } - - /** - * @param Map<string,string> $errors - */ - #[ValidationErrorsHandler] - public function handleValidationErrors( - Response $response, - #[ValidationErrors] - Map $errors, - ): HttpInterceptableInterface { - $response->status(400); - - return new TwigTemplate('turbo/auth/login_form.twig', [ - 'errors' => $errors, - ]); - } } ``` diff --git a/src/Attribute/OnParameterResolution.php b/src/Attribute/OnParameterResolution.php new file mode 100644 index 0000000000000000000000000000000000000000..5127b23f1960222fe59ee94108c5ef62d5b6a397 --- /dev/null +++ b/src/Attribute/OnParameterResolution.php @@ -0,0 +1,21 @@ +<?php + +declare(strict_types=1); + +namespace Distantmagic\Resonance\Attribute; + +use Attribute; +use Distantmagic\Resonance\Attribute as BaseAttribute; +use Distantmagic\Resonance\HttpControllerParameterResolutionStatus; + +#[Attribute(Attribute::TARGET_PARAMETER)] +final readonly class OnParameterResolution extends BaseAttribute +{ + /** + * @param non-empty-string $forwardTo + */ + public function __construct( + public string $forwardTo, + public HttpControllerParameterResolutionStatus $status, + ) {} +} diff --git a/src/Attribute/ValidationErrors.php b/src/Attribute/ValidationErrors.php deleted file mode 100644 index 493564b41038684d2710643988052de5b0260696..0000000000000000000000000000000000000000 --- a/src/Attribute/ValidationErrors.php +++ /dev/null @@ -1,11 +0,0 @@ -<?php - -declare(strict_types=1); - -namespace Distantmagic\Resonance\Attribute; - -use Attribute; -use Distantmagic\Resonance\Attribute as BaseAttribute; - -#[Attribute(Attribute::TARGET_PARAMETER)] -final readonly class ValidationErrors extends BaseAttribute {} diff --git a/src/Attribute/ValidationErrorsHandler.php b/src/Attribute/ValidationErrorsHandler.php deleted file mode 100644 index e6b319097e60ac0a6fa735e9a46dea81ee35f6e7..0000000000000000000000000000000000000000 --- a/src/Attribute/ValidationErrorsHandler.php +++ /dev/null @@ -1,11 +0,0 @@ -<?php - -declare(strict_types=1); - -namespace Distantmagic\Resonance\Attribute; - -use Attribute; -use Distantmagic\Resonance\Attribute as BaseAttribute; - -#[Attribute(Attribute::TARGET_METHOD)] -final readonly class ValidationErrorsHandler extends BaseAttribute {} diff --git a/src/HttpControllerParameter.php b/src/HttpControllerParameter.php index b7488bcdd531ce38a17e10db57f55dd8b173a68d..a28ea3c8e45064409bcc0281014ba1e27775569f 100644 --- a/src/HttpControllerParameter.php +++ b/src/HttpControllerParameter.php @@ -4,6 +4,8 @@ declare(strict_types=1); namespace Distantmagic\Resonance; +use Distantmagic\Resonance\Attribute\OnParameterResolution; +use Ds\Set; use ReflectionParameter; /** @@ -11,15 +13,30 @@ use ReflectionParameter; */ readonly class HttpControllerParameter { + public ?OnParameterResolution $onParameterResolution; + /** - * @param TAttribute $attribute + * @param Set<TAttribute> $attributes * @param class-string $className * @param non-empty-string $name */ public function __construct( public ReflectionParameter $reflectionParameter, - public ?Attribute $attribute, + public Set $attributes, public string $className, public string $name, - ) {} + ) { + $this->onParameterResolution = $this->findOnParameterResolutionAttribute(); + } + + private function findOnParameterResolutionAttribute(): ?OnParameterResolution + { + foreach ($this->attributes as $attribute) { + if ($attribute instanceof OnParameterResolution) { + return $attribute; + } + } + + return null; + } } diff --git a/src/HttpControllerParameterResolution.php b/src/HttpControllerParameterResolution.php index 1d6d9f0cb6be0bceef0674ff8fccd8febd25982c..b568a6934e1e5af7a02ece9aa20fdc5e32dada93 100644 --- a/src/HttpControllerParameterResolution.php +++ b/src/HttpControllerParameterResolution.php @@ -4,8 +4,14 @@ declare(strict_types=1); namespace Distantmagic\Resonance; +/** + * @template TValue + */ readonly class HttpControllerParameterResolution { + /** + * @param TValue $value + */ public function __construct( public HttpControllerParameterResolutionStatus $status, public mixed $value = null, diff --git a/src/HttpControllerParameterResolutionStatus.php b/src/HttpControllerParameterResolutionStatus.php index 70b2cdff3579eb705147811f7548c65651ff0c29..79551e04af6b6e35d9926e32f0532426fd4ec5e1 100644 --- a/src/HttpControllerParameterResolutionStatus.php +++ b/src/HttpControllerParameterResolutionStatus.php @@ -8,6 +8,7 @@ enum HttpControllerParameterResolutionStatus { case Forbidden; case MissingUrlParameterValue; + case NoResolver; case NotFound; case Success; case ValidationErrors; diff --git a/src/HttpControllerParameterResolverAggregate.php b/src/HttpControllerParameterResolverAggregate.php index 190e65bfa5f216a298b49da613051b3e17a9c0eb..9764f66174a421c39c7e29dc38f2cd3df24914cc 100644 --- a/src/HttpControllerParameterResolverAggregate.php +++ b/src/HttpControllerParameterResolverAggregate.php @@ -26,24 +26,32 @@ readonly class HttpControllerParameterResolverAggregate Response $response, HttpControllerParameter $parameter, ): HttpControllerParameterResolution { - $attribute = $parameter->attribute; - - if (!$attribute) { - throw new LogicException('To use the attribute resolver, attribute must be provided.'); + /** + * @var null|HttpControllerParameterResolution $resolved + */ + $resolved = null; + + foreach ($parameter->attributes as $attribute) { + if ($this->resolvers->hasKey($attribute::class)) { + if (!is_null($resolved)) { + throw new LogicException('Ambiguous parameter resolution. You can only use one resolving attribute.'); + } + + $resolved = $this->resolvers->get($attribute::class)->resolve( + $request, + $response, + $parameter, + $attribute, + ); + } } - if ($this->resolvers->hasKey($parameter->attribute::class)) { - return $this->resolvers->get($parameter->attribute::class)->resolve( - $request, - $response, - $parameter, - $attribute, - ); + if ($resolved) { + return $resolved; } - throw new LogicException(sprintf( - 'There is no resolver registered for attribute: %s', - $parameter->attribute::class, - )); + return new HttpControllerParameterResolution( + status: HttpControllerParameterResolutionStatus::NoResolver, + ); } } diff --git a/src/HttpControllerReflectionMethod.php b/src/HttpControllerReflectionMethod.php index 76f3fba19db80c7d021e7f06e921625d04f7eece..a6d7205f501a7b7bbf83da9724a5fccea9a7ca52 100644 --- a/src/HttpControllerReflectionMethod.php +++ b/src/HttpControllerReflectionMethod.php @@ -7,7 +7,7 @@ namespace Distantmagic\Resonance; use Distantmagic\Resonance\Attribute\CurrentRequest; use Distantmagic\Resonance\Attribute\CurrentResponse; use Distantmagic\Resonance\HttpResponder\HttpController; -use Ds\Map; +use Ds\Set; use Generator; use ReflectionAttribute; use ReflectionClass; @@ -23,9 +23,9 @@ use Swoole\Http\Response; readonly class HttpControllerReflectionMethod { /** - * @var Map<string, HttpControllerParameter> + * @var Set<HttpControllerParameter> */ - public Map $parameters; + public Set $parameters; /** * @param ReflectionClass<HttpController> $reflectionClass @@ -34,7 +34,7 @@ readonly class HttpControllerReflectionMethod public ReflectionClass $reflectionClass, private ReflectionMethod $reflectionMethod, ) { - $this->parameters = new Map(); + $this->parameters = new Set(); $this->assertReturnTypes(); @@ -117,9 +117,9 @@ readonly class HttpControllerReflectionMethod ); } - $this->parameters->put($name, new HttpControllerParameter( + $this->parameters->add(new HttpControllerParameter( $reflectionParameter, - $this->getParameterAttribute($reflectionParameter, $className), + $this->getParameterAttributes($reflectionParameter, $className), $className, $name, )); @@ -148,34 +148,40 @@ readonly class HttpControllerReflectionMethod /** * @param class-string $className + * + * @return Set<Attribute> */ - private function getParameterAttribute( + private function getParameterAttributes( ReflectionParameter $reflectionParameter, string $className, - ): ?Attribute { + ): Set { $routeParameterAttributes = $reflectionParameter->getAttributes( Attribute::class, ReflectionAttribute::IS_INSTANCEOF, ); + /** + * @var Set<Attribute> + */ + $attributes = new Set(); + switch (count($routeParameterAttributes)) { case 0: if (is_a($className, Request::class, true)) { - return new CurrentRequest(); - } - if (is_a($className, Response::class, true)) { - return new CurrentResponse(); + $attributes->add(new CurrentRequest()); + } elseif (is_a($className, Response::class, true)) { + $attributes->add(new CurrentResponse()); } break; - case 1: + default: foreach ($routeParameterAttributes as $routeParameterAttribute) { - return $routeParameterAttribute->newInstance(); + $attributes->add($routeParameterAttribute->newInstance()); } break; } - return null; + return $attributes; } } diff --git a/src/HttpResponder/HttpController.php b/src/HttpResponder/HttpController.php index c51c701199f8faa170f97e83bc505ae8907dc4f2..d02085290f820328ab633e6f3c77f625c46e3b58 100644 --- a/src/HttpResponder/HttpController.php +++ b/src/HttpResponder/HttpController.php @@ -5,11 +5,9 @@ declare(strict_types=1); namespace Distantmagic\Resonance\HttpResponder; use Closure; -use Distantmagic\Resonance\Attribute\CurrentRequest; -use Distantmagic\Resonance\Attribute\CurrentResponse; -use Distantmagic\Resonance\Attribute\ValidationErrors; -use Distantmagic\Resonance\Attribute\ValidationErrorsHandler; +use Distantmagic\Resonance\Attribute\OnParameterResolution; use Distantmagic\Resonance\HttpControllerDependencies; +use Distantmagic\Resonance\HttpControllerParameterResolution; use Distantmagic\Resonance\HttpControllerParameterResolutionStatus; use Distantmagic\Resonance\HttpControllerParameterResolverAggregate; use Distantmagic\Resonance\HttpControllerReflectionMethod; @@ -22,6 +20,7 @@ use Distantmagic\Resonance\HttpResponderInterface; use Ds\Map; use LogicException; use ReflectionClass; +use ReflectionMethod; use Swoole\Http\Request; use Swoole\Http\Response; @@ -29,9 +28,18 @@ abstract readonly class HttpController extends HttpResponder { private BadRequest $badRequest; private Forbidden $forbidden; + + /** + * @var Map<non-empty-string,Closure> + */ + private Map $forwardableMethodCallbacks; + + /** + * @var Map<non-empty-string,HttpControllerReflectionMethod> + */ + private Map $forwardableMethodReflections; + private HttpControllerReflectionMethod $handleReflection; - private ?Closure $handleValidationErrorsCallback; - private ?HttpControllerReflectionMethod $handleValidationErrorsReflection; private HttpControllerParameterResolverAggregate $httpControllerParameterResolverAggregate; private PageNotFound $pageNotFound; @@ -39,39 +47,35 @@ abstract readonly class HttpController extends HttpResponder { $this->badRequest = $controllerDependencies->badRequest; $this->forbidden = $controllerDependencies->forbidden; + $this->forwardableMethodCallbacks = new Map(); + $this->forwardableMethodReflections = new Map(); $this->httpControllerParameterResolverAggregate = $controllerDependencies->httpControllerParameterResolverAggregate; $this->pageNotFound = $controllerDependencies->pageNotFound; $reflectionClass = new ReflectionClass($this); - /** - * @var null|Closure - */ - $handleValidationErrorsCallback = null; - - /** - * @var null|HttpControllerReflectionMethod - */ - $handleValidationErrorsReflection = null; - - foreach ($reflectionClass->getMethods() as $validationErrorsReflectionMethod) { - if (!empty($validationErrorsReflectionMethod->getAttributes(ValidationErrorsHandler::class))) { - $handleValidationErrorsReflection = new HttpControllerReflectionMethod( - $reflectionClass, - $validationErrorsReflectionMethod, - ); - $handleValidationErrorsCallback = $validationErrorsReflectionMethod->getClosure($this); - } - } - - $this->handleValidationErrorsCallback = $handleValidationErrorsCallback; - $this->handleValidationErrorsReflection = $handleValidationErrorsReflection; - $this->handleReflection = $controllerDependencies ->httpControllerReflectionMethodCollection ->reflectionMethods ->get(static::class) ; + + foreach ($this->handleReflection->parameters as $parameter) { + foreach ($parameter->attributes as $attribute) { + if ($attribute instanceof OnParameterResolution && !($this->forwardableMethodReflections->hasKey($attribute->forwardTo))) { + $forwardableMethodReflection = new ReflectionMethod($this, $attribute->forwardTo); + + $this->forwardableMethodReflections->put( + $attribute->forwardTo, + new HttpControllerReflectionMethod($reflectionClass, $forwardableMethodReflection), + ); + $this->forwardableMethodCallbacks->put( + $attribute->forwardTo, + $forwardableMethodReflection->getClosure($this), + ); + } + } + } } final public function respond(Request $request, Response $response): null|HttpInterceptableInterface|HttpResponderInterface @@ -81,11 +85,6 @@ abstract readonly class HttpController extends HttpResponder */ $resolvedParameterValues = []; - /** - * @var null|Map<string,string> - */ - $validationErrors = null; - foreach ($this->handleReflection->parameters as $parameter) { $parameterResolution = $this->httpControllerParameterResolverAggregate->resolve( $request, @@ -93,6 +92,18 @@ abstract readonly class HttpController extends HttpResponder $parameter, ); + $onParameterResolution = $parameter->onParameterResolution; + + if ($onParameterResolution && $onParameterResolution->status === $parameterResolution->status) { + return $this->forwardResolvedParameter( + $request, + $response, + $this->forwardableMethodReflections->get($onParameterResolution->forwardTo), + $this->forwardableMethodCallbacks->get($onParameterResolution->forwardTo), + $parameterResolution, + ); + } + switch ($parameterResolution->status) { case HttpControllerParameterResolutionStatus::Forbidden: return $this->forbidden; @@ -108,43 +119,12 @@ abstract readonly class HttpController extends HttpResponder break; case HttpControllerParameterResolutionStatus::ValidationErrors: - if (!$validationErrors) { - /** - * @var Map<string,string> - */ - $validationErrors = new Map(); - } - - /** - * Let's assume that types are correct. Otherwise it - * would be necessary to iterate over the entire map - * during each request. - * - * @var Map<string,string> $parameterResolution->value - */ - $validationErrors->putAll($parameterResolution->value); - - break; + return $this->badRequest; default: throw new LogicException('Unsupported parameter resolution state'); } } - if ($validationErrors) { - if (!$this->handleValidationErrorsReflection || !$this->handleValidationErrorsCallback) { - return $this->badRequest; - } - - return $this->handleValidationErrors( - $request, - $response, - $this->handleValidationErrorsReflection, - $this->handleValidationErrorsCallback, - $resolvedParameterValues, - $validationErrors, - ); - } - /** * This method is dynamically built and it's checked in the * constructor. @@ -156,17 +136,12 @@ abstract readonly class HttpController extends HttpResponder return $this->handle(...$resolvedParameterValues); } - /** - * @param array <string,mixed> $resolvedParameterValues - * @param Map<string,string> $validationErrors - */ - private function handleValidationErrors( + private function forwardResolvedParameter( Request $request, Response $response, HttpControllerReflectionMethod $handleValidationErrorsReflection, Closure $handleValidationErrorsCallback, - array $resolvedParameterValues, - Map $validationErrors, + HttpControllerParameterResolution $httpControllerParameterResolution, ): null|HttpInterceptableInterface|HttpResponderInterface { /** * @var array <string,mixed> @@ -174,17 +149,14 @@ abstract readonly class HttpController extends HttpResponder $resolvedValidationHandlerParameters = []; foreach ($handleValidationErrorsReflection->parameters as $parameter) { - $attribute = $parameter->attribute; - /** * @var mixed explicitly mixed for typechecks */ $resolvedValidationHandlerParameters[$parameter->name] = match (true) { - array_key_exists($parameter->name, $resolvedParameterValues) => $resolvedParameterValues[$parameter->name], - is_a($attribute, ValidationErrors::class, true) => $validationErrors, - is_a($attribute, CurrentRequest::class, true) => $request, - is_a($attribute, CurrentResponse::class, true) => $response, - default => throw new LogicException('ValidationErrorsHandler can only use parameters that are already resolved in the handler: '.$parameter->name), + is_a($parameter->className, HttpControllerParameterResolution::class, true) => $httpControllerParameterResolution, + is_a($parameter->className, Request::class, true) => $request, + is_a($parameter->className, Response::class, true) => $response, + default => throw new LogicException('ForwardedTo handlers can only use parameters that are already resolved in the handler: '.$parameter->name), }; } diff --git a/src/OpenAPISchemaOperation.php b/src/OpenAPISchemaOperation.php index 1cb31b3466c5a649708abedb6d665e82c84e2506..20b6cd04a4ab62148efe5f38d27f553b8fcd4011 100644 --- a/src/OpenAPISchemaOperation.php +++ b/src/OpenAPISchemaOperation.php @@ -126,11 +126,11 @@ readonly class OpenAPISchemaOperation implements OpenAPISerializableFieldInterfa $parameters = []; foreach ($this->httpControllerReflectionMethod->parameters as $reflectionMethodParameter) { - if ($reflectionMethodParameter->attribute) { + foreach ($reflectionMethodParameter->attributes as $attribute) { $extractedParameters = $this ->openAPIRouteParameterExtractorAggregate ->extractFromHttpControllerParameter( - $reflectionMethodParameter->attribute, + $attribute, $reflectionMethodParameter->className, $reflectionMethodParameter->name, ) @@ -154,11 +154,11 @@ readonly class OpenAPISchemaOperation implements OpenAPISerializableFieldInterfa $requestBodyContents = []; foreach ($this->httpControllerReflectionMethod->parameters as $reflectionMethodParameter) { - if ($reflectionMethodParameter->attribute) { + foreach ($reflectionMethodParameter->attributes as $attribute) { $parameterResolvedValue = $this ->openAPIRouteRequestBodyContentExtractorAggregate ->extractFromHttpControllerParameter( - $reflectionMethodParameter->attribute, + $attribute, $reflectionMethodParameter->className, $reflectionMethodParameter->name, ) @@ -219,11 +219,11 @@ readonly class OpenAPISchemaOperation implements OpenAPISerializableFieldInterfa $mergedSecurityRequirements = []; foreach ($this->httpControllerReflectionMethod->parameters as $reflectionMethodParameter) { - if ($reflectionMethodParameter->attribute) { + foreach ($reflectionMethodParameter->attributes as $attribute) { $extractedSecurityRequirements = $this ->openAPIRouteSecurityRequirementExtractorAggregate ->extractFromHttpControllerParameter( - $reflectionMethodParameter->attribute, + $attribute, $reflectionMethodParameter->className, $reflectionMethodParameter->name, )