From 35bbb96087e809b6e5f55612d2c374659029928e Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 5 Apr 2024 17:07:18 +0200 Subject: [PATCH] feat(parametervalidator): parameter validation --- src/Metadata/Parameter.php | 19 ++++ ...meterResourceMetadataCollectionFactory.php | 105 +++++++++++++++-- .../Validator/ArrayItems.php | 3 + src/ParameterValidator/Validator/Bounds.php | 3 + src/ParameterValidator/Validator/Enum.php | 3 + src/ParameterValidator/Validator/Length.php | 3 + .../Validator/MultipleOf.php | 3 + src/ParameterValidator/Validator/Pattern.php | 3 + src/ParameterValidator/Validator/Required.php | 3 + .../Validator/ValidatorInterface.php | 3 + src/State/Provider/ParameterProvider.php | 2 +- .../ApiPlatformExtension.php | 2 + .../Resources/config/state/provider.xml | 5 - .../Bundle/Resources/config/state/state.xml | 5 + .../config/symfony/parameter_validator.xml | 11 ++ .../State/ParameterValidatorProvider.php | 75 ++++++++++++ .../TestBundle/ApiResource/WithParameter.php | 17 ++- .../Functional/Parameters/ValidationTests.php | 107 ++++++++++++++++++ 18 files changed, 358 insertions(+), 14 deletions(-) create mode 100644 src/Symfony/Bundle/Resources/config/symfony/parameter_validator.xml create mode 100644 src/Symfony/Validator/State/ParameterValidatorProvider.php create mode 100644 tests/Functional/Parameters/ValidationTests.php diff --git a/src/Metadata/Parameter.php b/src/Metadata/Parameter.php index d0e9bb8c067..8b28aa8e1e1 100644 --- a/src/Metadata/Parameter.php +++ b/src/Metadata/Parameter.php @@ -15,6 +15,7 @@ use ApiPlatform\OpenApi; use ApiPlatform\State\ProviderInterface; +use Symfony\Component\Validator\Constraint; /** * @experimental @@ -26,6 +27,7 @@ abstract class Parameter * @param array $extraProperties * @param ProviderInterface|callable|string|null $provider * @param FilterInterface|string|null $filter + * @param Constraint|Constraint[]|null $constraints */ public function __construct( protected ?string $key = null, @@ -37,6 +39,7 @@ public function __construct( protected ?string $description = null, protected ?bool $required = null, protected ?int $priority = null, + protected Constraint|array|null $constraints = null, protected ?array $extraProperties = [], ) { } @@ -89,6 +92,14 @@ public function getPriority(): ?int return $this->priority; } + /** + * @return Constraint|Constraint[]|null + */ + public function getConstraints(): Constraint|array|null + { + return $this->constraints; + } + /** * @return array */ @@ -178,6 +189,14 @@ public function withRequired(bool $required): static return $self; } + public function withConstraints(array|Constraint $constraints): static + { + $self = clone $this; + $self->constraints = $constraints; + + return $self; + } + /** * @param array $extraProperties */ diff --git a/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php index 00680c26fec..b4d0c8e973f 100644 --- a/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php +++ b/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php @@ -18,9 +18,22 @@ use ApiPlatform\Metadata\Parameter; use ApiPlatform\Metadata\Parameters; use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; -use ApiPlatform\OpenApi; +use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter; use ApiPlatform\Serializer\Filter\FilterInterface as SerializerFilterInterface; use Psr\Container\ContainerInterface; +use Symfony\Component\Validator\Constraints\Choice; +use Symfony\Component\Validator\Constraints\Count; +use Symfony\Component\Validator\Constraints\DivisibleBy; +use Symfony\Component\Validator\Constraints\GreaterThan; +use Symfony\Component\Validator\Constraints\GreaterThanOrEqual; +use Symfony\Component\Validator\Constraints\Length; +use Symfony\Component\Validator\Constraints\LessThan; +use Symfony\Component\Validator\Constraints\LessThanOrEqual; +use Symfony\Component\Validator\Constraints\NotBlank; +use Symfony\Component\Validator\Constraints\NotNull; +use Symfony\Component\Validator\Constraints\Regex; +use Symfony\Component\Validator\Constraints\Unique; +use Symfony\Component\Validator\Validator\ValidatorInterface; /** * Prepares Parameters documentation by reading its filter details and declaring an OpenApi parameter. @@ -96,20 +109,28 @@ private function setDefaults(string $key, Parameter $parameter, string $resource $parameter = $parameter->withSchema($schema); } + if (null === $parameter->getProperty() && ($property = $description[$key]['property'] ?? null)) { + $parameter = $parameter->withProperty($property); + } + + if (null === $parameter->getRequired() && ($required = $description[$key]['required'] ?? null)) { + $parameter = $parameter->withRequired($required); + } + if (null === $parameter->getOpenApi() && $openApi = $description[$key]['openapi'] ?? null) { - if ($openApi instanceof OpenApi\Model\Parameter) { + if ($openApi instanceof OpenApiParameter) { $parameter = $parameter->withOpenApi($openApi); - } - - if (\is_array($openApi)) { - $parameter = $parameter->withOpenApi(new OpenApi\Model\Parameter( + } elseif (\is_array($openApi)) { + // @phpstan-ignore-next-line + $schema = $schema ?? $openapi['schema'] ?? []; + $parameter = $parameter->withOpenApi(new OpenApiParameter( $key, $parameter instanceof HeaderParameterInterface ? 'header' : 'query', $description[$key]['description'] ?? '', $description[$key]['required'] ?? $openApi['required'] ?? false, $openApi['deprecated'] ?? false, $openApi['allowEmptyValue'] ?? true, - $schema ?? $openApi['schema'] ?? [], + $schema, $openApi['style'] ?? null, $openApi['explode'] ?? ('array' === ($schema['type'] ?? null)), $openApi['allowReserved'] ?? false, @@ -121,6 +142,76 @@ private function setDefaults(string $key, Parameter $parameter, string $resource } } + $schema = $parameter->getSchema() ?? $parameter->getOpenApi()?->getSchema(); + + // Only add validation if the Symfony Validator is installed + if (interface_exists(ValidatorInterface::class) && !$parameter->getConstraints()) { + $parameter = $this->addSchemaValidation($parameter, $schema, $parameter->getRequired() ?? $description['required'] ?? false, $parameter->getOpenApi()); + } + return $parameter; } + + private function addSchemaValidation(Parameter $parameter, ?array $schema = null, bool $required = false, ?OpenApiParameter $openApi = null): Parameter + { + $assertions = []; + + if ($required) { + $assertions[] = new NotNull(message: sprintf('The parameter "%s" is required.', $parameter->getKey())); + } + + if (isset($schema['exclusiveMinimum'])) { + $assertions[] = new GreaterThan(value: $schema['exclusiveMinimum']); + } + + if (isset($schema['exclusiveMaximum'])) { + $assertions[] = new LessThan(value: $schema['exclusiveMaximum']); + } + + if (isset($schema['minimum'])) { + $assertions[] = new GreaterThanOrEqual(value: $schema['minimum']); + } + + if (isset($schema['maximum'])) { + $assertions[] = new LessThanOrEqual(value: $schema['maximum']); + } + + if (isset($schema['pattern'])) { + $assertions[] = new Regex($schema['pattern']); + } + + if (isset($schema['maxLength']) || isset($schema['minLength'])) { + $assertions[] = new Length(min: $schema['minLength'] ?? null, max: $schema['maxLength'] ?? null); + } + + if (isset($schema['minItems']) || isset($schema['maxItems'])) { + $assertions[] = new Count(min: $schema['minItems'] ?? null, max: $schema['maxItems'] ?? null); + } + + if (isset($schema['multipleOf'])) { + $assertions[] = new DivisibleBy(value: $schema['multipleOf']); + } + + if ($schema['uniqueItems'] ?? false) { + $assertions[] = new Unique(); + } + + if (isset($schema['enum'])) { + $assertions[] = new Choice(choices: $schema['enum']); + } + + if (false === $openApi?->getAllowEmptyValue()) { + $assertions[] = new NotBlank(allowNull: !$required); + } + + if (!$assertions) { + return $parameter; + } + + if (1 === \count($assertions)) { + return $parameter->withConstraints($assertions[0]); + } + + return $parameter->withConstraints($assertions); + } } diff --git a/src/ParameterValidator/Validator/ArrayItems.php b/src/ParameterValidator/Validator/ArrayItems.php index 9b01ee02b46..c7d216aef7f 100644 --- a/src/ParameterValidator/Validator/ArrayItems.php +++ b/src/ParameterValidator/Validator/ArrayItems.php @@ -13,6 +13,9 @@ namespace ApiPlatform\ParameterValidator\Validator; +/** + * @deprecated use Parameter constraint instead + */ final class ArrayItems implements ValidatorInterface { use CheckFilterDeprecationsTrait; diff --git a/src/ParameterValidator/Validator/Bounds.php b/src/ParameterValidator/Validator/Bounds.php index a6eb374b45e..8d5c4c9d4d9 100644 --- a/src/ParameterValidator/Validator/Bounds.php +++ b/src/ParameterValidator/Validator/Bounds.php @@ -13,6 +13,9 @@ namespace ApiPlatform\ParameterValidator\Validator; +/** + * @deprecated use Parameter constraint instead + */ final class Bounds implements ValidatorInterface { use CheckFilterDeprecationsTrait; diff --git a/src/ParameterValidator/Validator/Enum.php b/src/ParameterValidator/Validator/Enum.php index 516f5a377c6..8055308f52c 100644 --- a/src/ParameterValidator/Validator/Enum.php +++ b/src/ParameterValidator/Validator/Enum.php @@ -13,6 +13,9 @@ namespace ApiPlatform\ParameterValidator\Validator; +/** + * @deprecated use Parameter constraint instead + */ final class Enum implements ValidatorInterface { use CheckFilterDeprecationsTrait; diff --git a/src/ParameterValidator/Validator/Length.php b/src/ParameterValidator/Validator/Length.php index c9c7a61f8dd..db1195cb849 100644 --- a/src/ParameterValidator/Validator/Length.php +++ b/src/ParameterValidator/Validator/Length.php @@ -13,6 +13,9 @@ namespace ApiPlatform\ParameterValidator\Validator; +/** + * @deprecated use Parameter constraint instead + */ final class Length implements ValidatorInterface { use CheckFilterDeprecationsTrait; diff --git a/src/ParameterValidator/Validator/MultipleOf.php b/src/ParameterValidator/Validator/MultipleOf.php index d1b3b3c4bc8..f1bb6d7143b 100644 --- a/src/ParameterValidator/Validator/MultipleOf.php +++ b/src/ParameterValidator/Validator/MultipleOf.php @@ -13,6 +13,9 @@ namespace ApiPlatform\ParameterValidator\Validator; +/** + * @deprecated use Parameter constraint instead + */ final class MultipleOf implements ValidatorInterface { use CheckFilterDeprecationsTrait; diff --git a/src/ParameterValidator/Validator/Pattern.php b/src/ParameterValidator/Validator/Pattern.php index 7110e852ecf..a27d83f6455 100644 --- a/src/ParameterValidator/Validator/Pattern.php +++ b/src/ParameterValidator/Validator/Pattern.php @@ -13,6 +13,9 @@ namespace ApiPlatform\ParameterValidator\Validator; +/** + * @deprecated use Parameter constraint instead + */ final class Pattern implements ValidatorInterface { use CheckFilterDeprecationsTrait; diff --git a/src/ParameterValidator/Validator/Required.php b/src/ParameterValidator/Validator/Required.php index c23cabab329..e5ea7e35cda 100644 --- a/src/ParameterValidator/Validator/Required.php +++ b/src/ParameterValidator/Validator/Required.php @@ -15,6 +15,9 @@ use ApiPlatform\State\Util\RequestParser; +/** + * @deprecated use Parameter constraint instead + */ final class Required implements ValidatorInterface { use CheckFilterDeprecationsTrait; diff --git a/src/ParameterValidator/Validator/ValidatorInterface.php b/src/ParameterValidator/Validator/ValidatorInterface.php index c946ab35a49..846c9ae4b01 100644 --- a/src/ParameterValidator/Validator/ValidatorInterface.php +++ b/src/ParameterValidator/Validator/ValidatorInterface.php @@ -13,6 +13,9 @@ namespace ApiPlatform\ParameterValidator\Validator; +/** + * @deprecated use Parameter constraint instead + */ interface ValidatorInterface { /** diff --git a/src/State/Provider/ParameterProvider.php b/src/State/Provider/ParameterProvider.php index 9ce068acc5f..f74348ab304 100644 --- a/src/State/Provider/ParameterProvider.php +++ b/src/State/Provider/ParameterProvider.php @@ -40,7 +40,6 @@ public function __construct(private readonly ?ProviderInterface $decorated = nul public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null { $request = $context['request'] ?? null; - if ($request && null === $request->attributes->get('_api_query_parameters')) { $queryString = RequestParser::getQueryString($request); $request->attributes->set('_api_query_parameters', $queryString ? RequestParser::parseRequestParams($queryString) : []); @@ -57,6 +56,7 @@ public function provide(Operation $operation, array $uriVariables = [], array $c $key = $parameter->getKey(); $parameters = $this->extractParameterValues($parameter, $request, $context); $parsedKey = explode('[:property]', $key); + if (isset($parsedKey[0]) && isset($parameters[$parsedKey[0]])) { $key = $parsedKey[0]; } diff --git a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index 2e6e5a8907d..eec0eedd75e 100644 --- a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -819,6 +819,7 @@ private function registerValidatorConfiguration(ContainerBuilder $container, arr $container->setParameter('api_platform.validator.legacy_validation_exception', $config['validator']['legacy_validation_exception'] ?? true); $loader->load('metadata/validator.xml'); $loader->load('validator/validator.xml'); + $loader->load('symfony/parameter_validator.xml'); if ($this->isConfigEnabled($container, $config['graphql'])) { $loader->load('graphql/validator.xml'); @@ -846,6 +847,7 @@ private function registerValidatorConfiguration(ContainerBuilder $container, arr if (!$config['validator']['query_parameter_validation']) { $container->removeDefinition('api_platform.listener.view.validate_query_parameters'); $container->removeDefinition('api_platform.validator.query_parameter_validator'); + $container->removeDefinition('api_platform.symfony.parameter_validator'); } } diff --git a/src/Symfony/Bundle/Resources/config/state/provider.xml b/src/Symfony/Bundle/Resources/config/state/provider.xml index d849872a182..56a42f70f87 100644 --- a/src/Symfony/Bundle/Resources/config/state/provider.xml +++ b/src/Symfony/Bundle/Resources/config/state/provider.xml @@ -25,11 +25,6 @@ - - - - - api_platform.symfony.main_controller diff --git a/src/Symfony/Bundle/Resources/config/state/state.xml b/src/Symfony/Bundle/Resources/config/state/state.xml index 6ab53fe4714..daf14a7baf6 100644 --- a/src/Symfony/Bundle/Resources/config/state/state.xml +++ b/src/Symfony/Bundle/Resources/config/state/state.xml @@ -54,5 +54,10 @@ + + + + + diff --git a/src/Symfony/Bundle/Resources/config/symfony/parameter_validator.xml b/src/Symfony/Bundle/Resources/config/symfony/parameter_validator.xml new file mode 100644 index 00000000000..a9e26ffc4a9 --- /dev/null +++ b/src/Symfony/Bundle/Resources/config/symfony/parameter_validator.xml @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/src/Symfony/Validator/State/ParameterValidatorProvider.php b/src/Symfony/Validator/State/ParameterValidatorProvider.php new file mode 100644 index 00000000000..62a4e9a26ea --- /dev/null +++ b/src/Symfony/Validator/State/ParameterValidatorProvider.php @@ -0,0 +1,75 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Symfony\Validator\State; + +use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\ProviderInterface; +use ApiPlatform\Validator\Exception\ValidationException; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\Validator\ConstraintViolation; +use Symfony\Component\Validator\ConstraintViolationList; +use Symfony\Component\Validator\Validator\ValidatorInterface; + +/** + * Validates parameters using the Symfony validator. + * + * @experimental + */ +final class ParameterValidatorProvider implements ProviderInterface +{ + public function __construct( + private readonly ProviderInterface $decorated, + private readonly ValidatorInterface $validator + ) { + } + + public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null + { + $body = $this->decorated->provide($operation, $uriVariables, $context); + if (!$context['request'] instanceof Request) { + return $body; + } + + $operation = $context['request']->attributes->get('_api_operation'); + foreach ($operation->getParameters() ?? [] as $parameter) { + if (!$constraints = $parameter->getConstraints()) { + continue; + } + + $value = $parameter->getExtraProperties()['_api_values'][$parameter->getKey()] ?? null; + $violations = $this->validator->validate($value, $constraints); + if (0 !== \count($violations)) { + $constraintViolationList = new ConstraintViolationList(); + foreach ($violations as $violation) { + $constraintViolationList->add(new ConstraintViolation( + $violation->getMessage(), + $violation->getMessageTemplate(), + $violation->getParameters(), + $violation->getRoot(), + $parameter->getProperty() ?? $parameter->getKey(), + $violation->getInvalidValue(), + $violation->getPlural(), + $violation->getCode(), + $violation->getConstraint(), + $violation->getCause() + )); + } + + throw new ValidationException($constraintViolationList); + } + } + + return $body; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/WithParameter.php b/tests/Fixtures/TestBundle/ApiResource/WithParameter.php index cf9086eca92..a2dbf296ceb 100644 --- a/tests/Fixtures/TestBundle/ApiResource/WithParameter.php +++ b/tests/Fixtures/TestBundle/ApiResource/WithParameter.php @@ -19,6 +19,7 @@ use ApiPlatform\Metadata\Link; use ApiPlatform\Metadata\Parameter; use ApiPlatform\Metadata\QueryParameter; +use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter; use ApiPlatform\Serializer\Filter\GroupFilter; use ApiPlatform\Tests\Fixtures\TestBundle\Parameter\CustomGroupParameterProvider; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; @@ -42,12 +43,26 @@ provider: [self::class, 'provide'] )] #[GetCollection( - uriTemplate: 'with_parameters_collection', + uriTemplate: 'with_parameters_collection{._format}', parameters: [ 'hydra' => new QueryParameter(property: 'a', required: true), ], provider: [self::class, 'collectionProvider'] )] +#[GetCollection( + uriTemplate: 'validate_parameters{._format}', + parameters: [ + 'enum' => new QueryParameter(schema: ['enum' => ['a', 'b'], 'uniqueItems' => true]), + 'num' => new QueryParameter(schema: ['minimum' => 1, 'maximum' => 3]), + 'exclusiveNum' => new QueryParameter(schema: ['exclusiveMinimum' => 1, 'exclusiveMaximum' => 3]), + 'blank' => new QueryParameter(openApi: new OpenApiParameter(name: 'blank', in: 'query', allowEmptyValue: false)), + 'length' => new QueryParameter(schema: ['maxLength' => 1, 'minLength' => 3]), + 'array' => new QueryParameter(schema: ['minItems' => 2, 'maxItems' => 3]), + 'multipleOf' => new QueryParameter(schema: ['multipleOf' => 2]), + 'pattern' => new QueryParameter(schema: ['pattern' => '/\d/']), + ], + provider: [self::class, 'collectionProvider'] +)] #[QueryParameter(key: 'everywhere')] class WithParameter { diff --git a/tests/Functional/Parameters/ValidationTests.php b/tests/Functional/Parameters/ValidationTests.php new file mode 100644 index 00000000000..2e3d80224ea --- /dev/null +++ b/tests/Functional/Parameters/ValidationTests.php @@ -0,0 +1,107 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional\Parameters; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; + +final class ValidationTests extends ApiTestCase +{ + public function testWithGroupFilter(): void + { + $response = self::createClient()->request('GET', 'with_parameters_collection'); + $this->assertArraySubset(['violations' => [['propertyPath' => 'a', 'message' => 'The parameter "hydra" is required.']]], $response->toArray(false)); + $response = self::createClient()->request('GET', 'with_parameters_collection?hydra'); + $this->assertResponseIsSuccessful(); + } + + /** + * @dataProvider provideQueryStrings + * + * @param array $expectedViolations + */ + public function testValidation(string $queryString, array $expectedViolations): void + { + $response = self::createClient()->request('GET', 'validate_parameters?'.$queryString); + $this->assertArraySubset([ + 'violations' => $expectedViolations, + ], $response->toArray(false)); + } + + public function provideQueryStrings(): array + { + return [ + [ + 'enum[]=c&enum[]=c', + [ + [ + 'propertyPath' => 'enum', 'message' => 'This collection should contain only unique elements.', + ], + [ + 'propertyPath' => 'enum', 'message' => 'The value you selected is not a valid choice.', + ], + ], + ], + [ + 'blank=', + [ + [ + 'propertyPath' => 'blank', 'message' => 'This value should not be blank.', + ], + ], + ], + [ + 'length=toolong', + [ + ['propertyPath' => 'length', 'message' => 'This value is too long. It should have 1 character or less.'], + ], + ], + [ + 'multipleOf=3', + [ + ['propertyPath' => 'multipleOf', 'message' => 'This value should be a multiple of 2.'], + ], + ], + [ + 'pattern=no', + [ + ['propertyPath' => 'pattern', 'message' => 'This value is not valid.'], + ], + ], + [ + 'array[]=1', + [ + ['propertyPath' => 'array', 'message' => 'This collection should contain 2 elements or more.'], + ], + ], + [ + 'num=5', + [ + ['propertyPath' => 'num', 'message' => 'This value should be less than or equal to 3.'], + ], + ], + [ + 'exclusiveNum=5', + [ + ['propertyPath' => 'exclusiveNum', 'message' => 'This value should be less than 3.'], + ], + ], + ]; + } + + public function testBlank(): void + { + $response = self::createClient()->request('GET', 'validate_parameters?blank=f'); + $this->assertResponseIsSuccessful(); + } +}