diff --git a/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php index 090ae1576a6..327abc62061 100644 --- a/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php +++ b/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php @@ -19,9 +19,9 @@ 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\All; use Symfony\Component\Validator\Constraints\Choice; use Symfony\Component\Validator\Constraints\Count; use Symfony\Component\Validator\Constraints\DivisibleBy; @@ -31,6 +31,7 @@ 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; @@ -112,18 +113,18 @@ private function setDefaults(string $key, Parameter $parameter, string $resource $parameter = $parameter->withProperty($property); } - if (null === $parameter->getRequired() && ($required = $description[$key]['required'])) { + 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)) { $schema = $schema ?? $openapi['schema'] ?? []; - $parameter = $parameter->withOpenApi(new OpenApi\Model\Parameter( + $parameter = $parameter->withOpenApi(new OpenApiParameter( $key, $parameter instanceof HeaderParameterInterface ? 'header' : 'query', $description[$key]['description'] ?? '', @@ -142,69 +143,74 @@ private function setDefaults(string $key, Parameter $parameter, string $resource } } + $schema = $parameter->getSchema() ?? $parameter->getOpenApi()?->getSchema(); if (!$parameter->getConstraint()) { - $parameter = $this->addSchemaValidation($parameter, $schema, $description['required'] ?? false, $openApi); + $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, ?array $openApi = null): 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'], propertyPath: $parameter->getProperty() ?? $parameter->getKey()); + $assertions[] = new GreaterThan(value: $schema['exclusiveMinimum']); } if (isset($schema['exclusiveMaximum'])) { - $assertions[] = new LessThan(value: $schema['exclusiveMaximum'], propertyPath: $parameter->getProperty() ?? $parameter->getKey()); + $assertions[] = new LessThan(value: $schema['exclusiveMaximum']); } if (isset($schema['minimum'])) { - $assertions[] = new GreaterThanOrEqual(value: $schema['minimum'], propertyPath: $parameter->getProperty() ?? $parameter->getKey()); + $assertions[] = new GreaterThanOrEqual(value: $schema['minimum']); } if (isset($schema['maximum'])) { - $assertions[] = new LessThanOrEqual(value: $schema['maximum'], propertyPath: $parameter->getProperty() ?? $parameter->getKey()); + $assertions[] = new LessThanOrEqual(value: $schema['maximum']); } - if ($required && !($openApi['allowEmptyValue'] ?? false)) { - $assertions[] = new NotBlank(); + if (isset($schema['pattern'])) { + $assertions[] = new Regex($schema['pattern']); } - if (isset($openApi['pattern'])) { - $assertions[] = new Regex($openApi['pattern'], message: sprintf('Query parameter "%s" must match pattern %s', $parameter->getKey(), $openApi['pattern'])); + if (isset($schema['maxLength']) || isset($schema['minLength'])) { + $assertions[] = new Length(min: $schema['minLength'] ?? null, max: $schema['maxLength'] ?? null); } - if (isset($openApi['maxLength']) || isset($openApi['minLength'])) { - $assertions[] = new Length(min: $openApi['minLength'] ?? null, max: $openApi['maxLength'] ?? null); + if (isset($schema['minItems']) || isset($schema['maxItems'])) { + $assertions[] = new Count(min: $schema['minItems'] ?? null, max: $schema['maxItems'] ?? null); } - if (isset($openApi['maxLength']) || isset($openApi['minLength'])) { - $assertions[] = new Length(min: $openApi['minLength'] ?? null, max: $openApi['maxLength'] ?? null); + if (isset($schema['multipleOf'])) { + $assertions[] = new DivisibleBy(value: $schema['multipleOf']); } - if (isset($openApi['minItems']) || isset($openApi['maxItems'])) { - $assertions[] = new Count(min: $openApi['minItems'] ?? null, max: $openApi['maxItems'] ?? null); + if ($schema['uniqueItems'] ?? false) { + $assertions[] = new Unique(); } - if ($openApi['uniqueItems'] ?? false) { - $assertions[] = new Unique(); + if (isset($schema['enum'])) { + $assertions[] = new Choice(choices: $schema['enum']); } - if (isset($openApi['enum'])) { - $assertions[] = new Choice(choices: $openApi['enum']); + if (false === $openApi?->getAllowEmptyValue()) { + $assertions[] = new NotBlank(allowNull: !$required); } - if (isset($openApi['enum'])) { - $assertions[] = new Choice(choices: $openApi['enum']); + if (!$assertions) { + return $parameter; } - if (isset($openApi['multipleOf'])) { - $assertions[] = new DivisibleBy(value: $openApi['multipleOf']); + if (1 === \count($assertions)) { + return $parameter->withConstraint($assertions[0]); } - return $parameter->withConstraint(new All($assertions)); + return $parameter->withConstraint($assertions); } } diff --git a/src/Symfony/Validator/State/ParameterValidatorProvider.php b/src/Symfony/Validator/State/ParameterValidatorProvider.php index 72b6d3c880e..8f189f4206a 100644 --- a/src/Symfony/Validator/State/ParameterValidatorProvider.php +++ b/src/Symfony/Validator/State/ParameterValidatorProvider.php @@ -16,6 +16,7 @@ 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; @@ -25,37 +26,29 @@ * * @experimental */ -final class ParameterValidatorProvider implements ProviderInterface +final readonly class ParameterValidatorProvider implements ProviderInterface { public function __construct( - private readonly ProviderInterface $decorated, - private readonly ValidatorInterface $validator + private ProviderInterface $decorated, + private 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) { + if (!$context['request'] instanceof Request) { return $body; } $operation = $context['request']->attributes->get('_api_operation'); foreach ($operation->getParameters() as $parameter) { - if ($parameter->getRequired() && !\array_key_exists('_api_values', $parameter->getExtraProperties())) { - throw new ValidationException(new ConstraintViolationList([new ConstraintViolation(sprintf('Parameter "%s" is required.', $parameter->getKey()), null, [], null, null, null)])); - } - 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); + $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) { diff --git a/tests/Fixtures/TestBundle/ApiResource/WithParameter.php b/tests/Fixtures/TestBundle/ApiResource/WithParameter.php index 6426a2c7a41..a2dbf296ceb 100644 --- a/tests/Fixtures/TestBundle/ApiResource/WithParameter.php +++ b/tests/Fixtures/TestBundle/ApiResource/WithParameter.php @@ -19,11 +19,11 @@ 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; use Symfony\Component\Serializer\Attribute\Groups; -use Symfony\Component\Validator\Constraints\NotBlank; #[Get( uriTemplate: 'with_parameters/{id}{._format}', @@ -45,7 +45,21 @@ #[GetCollection( uriTemplate: 'with_parameters_collection{._format}', parameters: [ - 'hydra' => new QueryParameter(property: 'a', required: true, constraint: new NotBlank()), + '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'] )] diff --git a/tests/Functional/Parameters/ValidationTests.php b/tests/Functional/Parameters/ValidationTests.php index e7e57fc22cf..2e3d80224ea 100644 --- a/tests/Functional/Parameters/ValidationTests.php +++ b/tests/Functional/Parameters/ValidationTests.php @@ -19,7 +19,89 @@ final class ValidationTests extends ApiTestCase { public function testWithGroupFilter(): void { - $response = self::createClient()->request('GET', 'with_parameters_collection?hydra='); - $this->assertArraySubset(['violations' => [['propertyPath' => 'a', 'message' => 'This value should not be blank.']]], $response->toArray(false)); + $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(); } }