diff --git a/src/Metadata/Parameter.php b/src/Metadata/Parameter.php index d0e9bb8c06..018a27d66a 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 @@ -22,10 +23,11 @@ abstract class Parameter { /** - * @param array{type?: string}|null $schema - * @param array $extraProperties - * @param ProviderInterface|callable|string|null $provider - * @param FilterInterface|string|null $filter + * @param array{type?: string}|null $schema + * @param array $extraProperties + * @param ProviderInterface|callable|string|null $provider + * @param FilterInterface|string|null $filter + * @param Symfony\Component\Validator\Constraint|string|null $constraint */ 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 array|Constraint|null $constraint = null, protected ?array $extraProperties = [], ) { } @@ -89,6 +92,14 @@ public function getPriority(): ?int return $this->priority; } + /** + * @return Constraint|Constraint[]|null + */ + public function getConstraint(): array|Constraint|null + { + return $this->constraint; + } + /** * @return array */ @@ -178,6 +189,14 @@ public function withRequired(bool $required): static return $self; } + public function withConstraint(array|Constraint $constraint): static + { + $self = clone $this; + $self->constraint = $constraint; + + return $self; + } + /** * @param array $extraProperties */ diff --git a/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php index 00680c26fe..a185050419 100644 --- a/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php +++ b/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php @@ -21,6 +21,11 @@ use ApiPlatform\OpenApi; use ApiPlatform\Serializer\Filter\FilterInterface as SerializerFilterInterface; use Psr\Container\ContainerInterface; +use Symfony\Component\Validator\Constraints\GreaterThan; +use Symfony\Component\Validator\Constraints\GreaterThanOrEqual; +use Symfony\Component\Validator\Constraints\LessThan; +use Symfony\Component\Validator\Constraints\LessThanOrEqual; +use Symfony\Component\Validator\Constraints\Range; /** * Prepares Parameters documentation by reading its filter details and declaring an OpenApi parameter. @@ -96,12 +101,17 @@ 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->getOpenApi() && $openApi = $description[$key]['openapi'] ?? null) { if ($openApi instanceof OpenApi\Model\Parameter) { $parameter = $parameter->withOpenApi($openApi); } if (\is_array($openApi)) { + $schema = $schema ?? $openapi['schema'] ?? []; $parameter = $parameter->withOpenApi(new OpenApi\Model\Parameter( $key, $parameter instanceof HeaderParameterInterface ? 'header' : 'query', @@ -109,7 +119,7 @@ private function setDefaults(string $key, Parameter $parameter, string $resource $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 +131,40 @@ private function setDefaults(string $key, Parameter $parameter, string $resource } } + if (!$parameter->getConstraint()) { + $parameter = $this->addSchemaValidation($schema); + } + + //  ArrayItems.php + //  Enum.php + //  Length.php + //  MultipleOf.php + //  Pattern.php + //  Required.php + return $parameter; } + + private function addSchemaValidation(Parameter $parameter) + { + $assertions = []; + + if (isset($schema['exclusiveMinimum'])) { + $assertions[] = new GreaterThan(value: $schema['exclusiveMinimum'], propertyPath: $parameter->getProperty() ?? $parameter->getKey()); + } + + if (isset($schema['exclusiveMaximum'])) { + $assertions[] = new LessThan(value: $schema['exclusiveMaximum'], propertyPath: $parameter->getProperty() ?? $parameter->getKey()); + } + + if (isset($schema['minimum'])) { + $assertions[] = new GreaterThanOrEqual(value: $schema['minimum'], propertyPath: $parameter->getProperty() ?? $parameter->getKey()); + } + + if (isset($schema['maximum'])) { + $assertions[] = new LessThanOrEqual(value: $schema['maximum'], propertyPath: $parameter->getProperty() ?? $parameter->getKey()); + } + + + } } diff --git a/src/Metadata/Tests/Fixtures/ApiResource/WithParameter.php b/src/Metadata/Tests/Fixtures/ApiResource/WithParameter.php index 08cce46ada..64622d44af 100644 --- a/src/Metadata/Tests/Fixtures/ApiResource/WithParameter.php +++ b/src/Metadata/Tests/Fixtures/ApiResource/WithParameter.php @@ -17,6 +17,7 @@ use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\HeaderParameter; use ApiPlatform\Metadata\QueryParameter; +use Symfony\Component\Validator\Constraints\Required; #[ApiResource( parameters: [ diff --git a/src/State/Provider/ParameterProvider.php b/src/State/Provider/ParameterProvider.php index 9ce068acc5..eaa315a272 100644 --- a/src/State/Provider/ParameterProvider.php +++ b/src/State/Provider/ParameterProvider.php @@ -57,6 +57,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 db186069ce..3fb654ea1f 100644 --- a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -818,6 +818,7 @@ private function registerValidatorConfiguration(ContainerBuilder $container, arr if (interface_exists(ValidatorInterface::class)) { $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'); @@ -845,6 +846,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/symfony/parameter_validator.xml b/src/Symfony/Bundle/Resources/config/symfony/parameter_validator.xml new file mode 100644 index 0000000000..a9e26ffc4a --- /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 0000000000..863a090d17 --- /dev/null +++ b/src/Symfony/Validator/State/ParameterValidatorProvider.php @@ -0,0 +1,79 @@ + + * + * 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\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'] ?? null) { + return $body; + } + + $operation = $context['request']->attributes->get('_api_operation'); + foreach ($operation->getParameters() as $parameter) { + if (!$constraints = $parameter->getConstraint()) { + continue; + } + + // This is computed in @see ApiPlatform\State\Provider\ParameterProvider + if (!\array_key_exists('_api_values', $parameter->getExtraProperties())) { + continue; + } + + $violations = $this->validator->validate(current($parameter->getExtraProperties()['_api_values']), $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 cf9086eca9..6426a2c7a4 100644 --- a/tests/Fixtures/TestBundle/ApiResource/WithParameter.php +++ b/tests/Fixtures/TestBundle/ApiResource/WithParameter.php @@ -23,6 +23,7 @@ use ApiPlatform\Tests\Fixtures\TestBundle\Parameter\CustomGroupParameterProvider; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\Serializer\Attribute\Groups; +use Symfony\Component\Validator\Constraints\NotBlank; #[Get( uriTemplate: 'with_parameters/{id}{._format}', @@ -42,9 +43,9 @@ provider: [self::class, 'provide'] )] #[GetCollection( - uriTemplate: 'with_parameters_collection', + uriTemplate: 'with_parameters_collection{._format}', parameters: [ - 'hydra' => new QueryParameter(property: 'a', required: true), + 'hydra' => new QueryParameter(property: 'a', required: true, constraint: new NotBlank()), ], provider: [self::class, 'collectionProvider'] )] diff --git a/tests/Functional/Parameters/ValidationTests.php b/tests/Functional/Parameters/ValidationTests.php new file mode 100644 index 0000000000..5f138c79ad --- /dev/null +++ b/tests/Functional/Parameters/ValidationTests.php @@ -0,0 +1,15 @@ +request('GET', 'with_parameters_collection?hydra='); + $this->assertArraySubset(['violations' => [['propertyPath' => 'a', 'message' => 'This value should not be blank.']]], $response->toArray(false)); + } + +}