From 6f022007a689e8a1ee112c94380045776fe09635 Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 26 Jun 2025 15:05:33 +0200 Subject: [PATCH] fix(validator): parameter validation list|string --- ...ationResourceMetadataCollectionFactory.php | 118 +------------- .../Util/ParameterValidationConstraints.php | 153 ++++++++++++++++++ .../TestBundle/ApiResource/WithParameter.php | 8 +- .../Functional/Parameters/ValidationTest.php | 9 ++ 4 files changed, 176 insertions(+), 112 deletions(-) create mode 100644 src/Validator/Util/ParameterValidationConstraints.php diff --git a/src/Validator/Metadata/Resource/Factory/ParameterValidationResourceMetadataCollectionFactory.php b/src/Validator/Metadata/Resource/Factory/ParameterValidationResourceMetadataCollectionFactory.php index fd6191dfe52..8f967b99473 100644 --- a/src/Validator/Metadata/Resource/Factory/ParameterValidationResourceMetadataCollectionFactory.php +++ b/src/Validator/Metadata/Resource/Factory/ParameterValidationResourceMetadataCollectionFactory.php @@ -20,28 +20,16 @@ use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter; +use ApiPlatform\Validator\Util\ParameterValidationConstraints; use Psr\Container\ContainerInterface; -use Symfony\Component\TypeInfo\Type\CollectionType; -use Symfony\Component\TypeInfo\Type\UnionType; -use Symfony\Component\Validator\Constraints\All; -use Symfony\Component\Validator\Constraints\Choice; -use Symfony\Component\Validator\Constraints\Collection; -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\Range; -use Symfony\Component\Validator\Constraints\Regex; -use Symfony\Component\Validator\Constraints\Type; -use Symfony\Component\Validator\Constraints\Unique; +/** + * @experimental + */ final class ParameterValidationResourceMetadataCollectionFactory implements ResourceMetadataCollectionFactoryInterface { + use ParameterValidationConstraints; + public function __construct( private readonly ?ResourceMetadataCollectionFactoryInterface $decorated = null, private readonly ?ContainerInterface $filterLocator = null, @@ -100,99 +88,7 @@ private function addSchemaValidation(Parameter $parameter, ?array $schema = null return $parameter; } - $schema ??= $parameter->getSchema(); - $required ??= $parameter->getRequired() ?? false; - $openApi ??= $parameter->getOpenApi(); - - // When it's an array of openapi parameters take the first one as it's probably just a variant of the query parameter, - // only getAllowEmptyValue is used here anyways - if (\is_array($openApi)) { - $openApi = $openApi[0]; - } elseif (false === $openApi) { - $openApi = null; - } - - $assertions = []; - $allowEmptyValue = $openApi?->getAllowEmptyValue(); - if (false === ($allowEmptyValue ?? $openApi?->getAllowEmptyValue())) { - $assertions[] = new NotBlank(allowNull: !$required); - } - - $minimum = $schema['exclusiveMinimum'] ?? $schema['minimum'] ?? null; - $exclusiveMinimum = isset($schema['exclusiveMinimum']); - $maximum = $schema['exclusiveMaximum'] ?? $schema['maximum'] ?? null; - $exclusiveMaximum = isset($schema['exclusiveMaximum']); - - if ($minimum && $maximum) { - if (!$exclusiveMinimum && !$exclusiveMaximum) { - $assertions[] = new Range(min: $minimum, max: $maximum); - } else { - $assertions[] = $exclusiveMinimum ? new GreaterThan(value: $minimum) : new GreaterThanOrEqual(value: $minimum); - $assertions[] = $exclusiveMaximum ? new LessThan(value: $maximum) : new LessThanOrEqual(value: $maximum); - } - } elseif ($minimum) { - $assertions[] = $exclusiveMinimum ? new GreaterThan(value: $minimum) : new GreaterThanOrEqual(value: $minimum); - } elseif ($maximum) { - $assertions[] = $exclusiveMaximum ? new LessThan(value: $maximum) : new LessThanOrEqual(value: $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['multipleOf'])) { - $assertions[] = new DivisibleBy(value: $schema['multipleOf']); - } - - if (isset($schema['enum'])) { - $assertions[] = new Choice(choices: $schema['enum']); - } - - if ($properties = $parameter->getExtraProperties()['_properties'] ?? []) { - $fields = []; - foreach ($properties as $propertyName) { - $fields[$propertyName] = $assertions; - } - - return $parameter->withConstraints(new Collection(fields: $fields, allowMissingFields: true)); - } - - $isCollectionType = fn ($t) => $t instanceof CollectionType; - $isCollection = $parameter->getNativeType()?->isSatisfiedBy($isCollectionType) ?? false; - - // type-info 7.2 - if (!$isCollection && $parameter->getNativeType() instanceof UnionType) { - foreach ($parameter->getNativeType()->getTypes() as $t) { - if ($isCollection = $t->isSatisfiedBy($isCollectionType)) { - break; - } - } - } - - if ($isCollection) { - $assertions = $assertions ? [new All($assertions)] : []; - } - - if ($required && false !== $allowEmptyValue) { - $assertions[] = new NotNull(message: \sprintf('The parameter "%s" is required.', $parameter->getKey())); - } - - if (isset($schema['minItems']) || isset($schema['maxItems'])) { - $assertions[] = new Count(min: $schema['minItems'] ?? null, max: $schema['maxItems'] ?? null); - } - - if ($schema['uniqueItems'] ?? false) { - $assertions[] = new Unique(); - } - - if (isset($schema['type']) && 'array' === $schema['type']) { - $assertions[] = new Type(type: 'array'); - } - + $assertions = $this->getParameterValidationConstraints($parameter, $schema, $required, $openApi); if (!$assertions) { return $parameter; } diff --git a/src/Validator/Util/ParameterValidationConstraints.php b/src/Validator/Util/ParameterValidationConstraints.php new file mode 100644 index 00000000000..424cad45665 --- /dev/null +++ b/src/Validator/Util/ParameterValidationConstraints.php @@ -0,0 +1,153 @@ + + * + * 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\Validator\Util; + +use ApiPlatform\Metadata\Parameter; +use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter; +use Symfony\Component\TypeInfo\Type\CollectionType; +use Symfony\Component\TypeInfo\Type\UnionType; +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\Constraints\All; +use Symfony\Component\Validator\Constraints\AtLeastOneOf; +use Symfony\Component\Validator\Constraints\Choice; +use Symfony\Component\Validator\Constraints\Collection; +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\Range; +use Symfony\Component\Validator\Constraints\Regex; +use Symfony\Component\Validator\Constraints\Sequentially; +use Symfony\Component\Validator\Constraints\Type; +use Symfony\Component\Validator\Constraints\Unique; + +/** + * Helper to get a set of validation constraints for a given Parameter. + * + * @experimental + */ +trait ParameterValidationConstraints +{ + /** + * @param Parameter $parameter readonly + * + * @return list + */ + public static function getParameterValidationConstraints(Parameter $parameter, ?array $schema = null, ?bool $required = null, ?OpenApiParameter $openApi = null): array + { + $schema ??= $parameter->getSchema(); + $required ??= $parameter->getRequired() ?? false; + $openApi ??= $parameter->getOpenApi(); + + // When it's an array of openapi parameters take the first one as it's probably just a variant of the query parameter, + // only getAllowEmptyValue is used here anyways + if (\is_array($openApi)) { + $openApi = $openApi[0]; + } elseif (false === $openApi) { + $openApi = null; + } + + $assertions = []; + $allowEmptyValue = $openApi?->getAllowEmptyValue(); + if (false === ($allowEmptyValue ?? $openApi?->getAllowEmptyValue())) { + $assertions[] = new NotBlank(allowNull: !$required); + } + + $minimum = $schema['exclusiveMinimum'] ?? $schema['minimum'] ?? null; + $exclusiveMinimum = isset($schema['exclusiveMinimum']); + $maximum = $schema['exclusiveMaximum'] ?? $schema['maximum'] ?? null; + $exclusiveMaximum = isset($schema['exclusiveMaximum']); + + if ($minimum && $maximum) { + if (!$exclusiveMinimum && !$exclusiveMaximum) { + $assertions[] = new Range(min: $minimum, max: $maximum); + } else { + $assertions[] = $exclusiveMinimum ? new GreaterThan(value: $minimum) : new GreaterThanOrEqual(value: $minimum); + $assertions[] = $exclusiveMaximum ? new LessThan(value: $maximum) : new LessThanOrEqual(value: $maximum); + } + } elseif ($minimum) { + $assertions[] = $exclusiveMinimum ? new GreaterThan(value: $minimum) : new GreaterThanOrEqual(value: $minimum); + } elseif ($maximum) { + $assertions[] = $exclusiveMaximum ? new LessThan(value: $maximum) : new LessThanOrEqual(value: $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['multipleOf'])) { + $assertions[] = new DivisibleBy(value: $schema['multipleOf']); + } + + if (isset($schema['enum'])) { + $assertions[] = new Choice(choices: $schema['enum']); + } + + if ($properties = $parameter->getExtraProperties()['_properties'] ?? []) { + $fields = []; + foreach ($properties as $propertyName) { + $fields[$propertyName] = $assertions; + } + + return [new Collection(fields: $fields, allowMissingFields: true)]; + } + + $isCollectionType = fn ($t) => $t instanceof CollectionType; + $isCollection = $parameter->getNativeType()?->isSatisfiedBy($isCollectionType) ?? false; + + // type-info 7.2 + if (!$isCollection && $parameter->getNativeType() instanceof UnionType) { + foreach ($parameter->getNativeType()->getTypes() as $t) { + if ($isCollection = $t->isSatisfiedBy($isCollectionType)) { + break; + } + } + } + + if ($isCollection) { + if (true === ($parameter->getCastToArray() ?? false)) { + $assertions = $assertions ? [new All($assertions)] : []; + } else { + $assertions = $assertions ? [new AtLeastOneOf([new Sequentially($assertions), new All($assertions)])] : []; + } + } + + if ($required && false !== $allowEmptyValue) { + $assertions[] = new NotNull(message: \sprintf('The parameter "%s" is required.', $parameter->getKey())); + } + + if (isset($schema['minItems']) || isset($schema['maxItems'])) { + $assertions[] = new Count(min: $schema['minItems'] ?? null, max: $schema['maxItems'] ?? null); + } + + if ($schema['uniqueItems'] ?? false) { + $assertions[] = new Unique(); + } + + if (isset($schema['type']) && 'array' === $schema['type']) { + $assertions[] = new Type(type: 'array'); + } + + return $assertions; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/WithParameter.php b/tests/Fixtures/TestBundle/ApiResource/WithParameter.php index 8c130804225..677574f2586 100644 --- a/tests/Fixtures/TestBundle/ApiResource/WithParameter.php +++ b/tests/Fixtures/TestBundle/ApiResource/WithParameter.php @@ -95,11 +95,17 @@ #[GetCollection( uriTemplate: 'validate_parameters{._format}', parameters: [ - 'enum' => new QueryParameter(schema: ['enum' => ['a', 'b'], 'uniqueItems' => true]), + 'enum' => new QueryParameter( + schema: ['enum' => ['a', 'b'], 'uniqueItems' => true], + castToArray: true + ), 'num' => new QueryParameter( schema: ['minimum' => 1, 'maximum' => 3], nativeType: new BuiltinType(TypeIdentifier::STRING), ), + 'numMultipleType' => new QueryParameter( + schema: ['minimum' => 1, 'maximum' => 3], + ), 'exclusiveNum' => new QueryParameter( schema: ['exclusiveMinimum' => 1, 'exclusiveMaximum' => 3], nativeType: new BuiltinType(TypeIdentifier::STRING), diff --git a/tests/Functional/Parameters/ValidationTest.php b/tests/Functional/Parameters/ValidationTest.php index 73b2752df46..b4963367315 100644 --- a/tests/Functional/Parameters/ValidationTest.php +++ b/tests/Functional/Parameters/ValidationTest.php @@ -107,6 +107,15 @@ public static function provideQueryStrings(): array ['propertyPath' => 'num', 'message' => 'This value should be between 1 and 3.'], ], ], + [ + 'numMultipleType=5', + [ + [ + 'propertyPath' => 'numMultipleType', + 'message' => 'This value should satisfy at least one of the following constraints: [1] This value should be between 1 and 3. [2] Each element of this collection should satisfy its own set of constraints.', + ], + ], + ], [ 'exclusiveNum=5', [