From a49bde1ea79ae4226b70c20f9bf967ac77e9ab89 Mon Sep 17 00:00:00 2001 From: Nathan Pesneau <129308244+NathanPesneau@users.noreply.github.com> Date: Mon, 16 Sep 2024 16:31:50 +0200 Subject: [PATCH] feat(laravel): filter validations rules * refactor(metadata): move parameter validation to the validator component * feat(laravel): validations rules filters * cs fixes * fix(laravel): eloquent filters validation * fix(laravel): eloquent filters * fixes * fix --------- Co-authored-by: soyuka Co-authored-by: Nathan --- src/Laravel/ApiPlatformProvider.php | 60 ++--- ...ationResourceMetadataCollectionFactory.php | 157 +++++++++++++ ...meterResourceMetadataCollectionFactory.php | 142 +----------- src/State/Util/ParameterParserTrait.php | 4 + .../Resources/config/validator/validator.xml | 6 + ...ationResourceMetadataCollectionFactory.php | 209 ++++++++++++++++++ .../Document/SearchFilterParameter.php | 1 + 7 files changed, 412 insertions(+), 167 deletions(-) create mode 100644 src/Laravel/Metadata/ParameterValidationResourceMetadataCollectionFactory.php create mode 100644 src/Validator/Metadata/Resource/Factory/ParameterValidationResourceMetadataCollectionFactory.php diff --git a/src/Laravel/ApiPlatformProvider.php b/src/Laravel/ApiPlatformProvider.php index e79089c906f..818265b4e6f 100644 --- a/src/Laravel/ApiPlatformProvider.php +++ b/src/Laravel/ApiPlatformProvider.php @@ -102,6 +102,7 @@ use ApiPlatform\Laravel\Metadata\CachePropertyMetadataFactory; use ApiPlatform\Laravel\Metadata\CachePropertyNameCollectionMetadataFactory; use ApiPlatform\Laravel\Metadata\CacheResourceCollectionMetadataFactory; +use ApiPlatform\Laravel\Metadata\ParameterValidationResourceMetadataCollectionFactory; use ApiPlatform\Laravel\Routing\IriConverter; use ApiPlatform\Laravel\Routing\Router as UrlGeneratorRouter; use ApiPlatform\Laravel\Routing\SkolemIriConverter; @@ -331,46 +332,49 @@ public function register(): void return new CacheResourceCollectionMetadataFactory( new EloquentResourceCollectionMetadataFactory( - new ParameterResourceMetadataCollectionFactory( - $this->app->make(PropertyNameCollectionFactoryInterface::class), - $this->app->make(PropertyMetadataFactoryInterface::class), - new AlternateUriResourceMetadataCollectionFactory( - new FiltersResourceMetadataCollectionFactory( - new FormatsResourceMetadataCollectionFactory( - new InputOutputResourceMetadataCollectionFactory( - new PhpDocResourceMetadataCollectionFactory( - new OperationNameResourceMetadataCollectionFactory( - new LinkResourceMetadataCollectionFactory( - $app->make(LinkFactoryInterface::class), - new UriTemplateResourceMetadataCollectionFactory( + new ParameterValidationResourceMetadataCollectionFactory( + new ParameterResourceMetadataCollectionFactory( + $this->app->make(PropertyNameCollectionFactoryInterface::class), + $this->app->make(PropertyMetadataFactoryInterface::class), + new AlternateUriResourceMetadataCollectionFactory( + new FiltersResourceMetadataCollectionFactory( + new FormatsResourceMetadataCollectionFactory( + new InputOutputResourceMetadataCollectionFactory( + new PhpDocResourceMetadataCollectionFactory( + new OperationNameResourceMetadataCollectionFactory( + new LinkResourceMetadataCollectionFactory( $app->make(LinkFactoryInterface::class), - $app->make(PathSegmentNameGeneratorInterface::class), - new NotExposedOperationResourceMetadataCollectionFactory( + new UriTemplateResourceMetadataCollectionFactory( $app->make(LinkFactoryInterface::class), - new AttributesResourceMetadataCollectionFactory( - new ConcernsResourceMetadataCollectionFactory( - null, + $app->make(PathSegmentNameGeneratorInterface::class), + new NotExposedOperationResourceMetadataCollectionFactory( + $app->make(LinkFactoryInterface::class), + new AttributesResourceMetadataCollectionFactory( + new ConcernsResourceMetadataCollectionFactory( + null, + $app->make(LoggerInterface::class), + $config->get('api-platform.defaults', []), + $config->get('api-platform.graphql.enabled'), + ), $app->make(LoggerInterface::class), $config->get('api-platform.defaults', []), $config->get('api-platform.graphql.enabled'), ), - $app->make(LoggerInterface::class), - $config->get('api-platform.defaults', []), - $config->get('api-platform.graphql.enabled'), - ), + ) ) ) ) ) - ) - ), - $formats, - $config->get('api-platform.patch_formats'), + ), + $formats, + $config->get('api-platform.patch_formats'), + ) ) - ) + ), + $app->make('filters'), + $app->make(CamelCaseToSnakeCaseNameConverter::class) ), - $app->make('filters'), - $app->make(CamelCaseToSnakeCaseNameConverter::class) + $app->make('filters') ) ), true === $config->get('app.debug') ? 'array' : 'file' diff --git a/src/Laravel/Metadata/ParameterValidationResourceMetadataCollectionFactory.php b/src/Laravel/Metadata/ParameterValidationResourceMetadataCollectionFactory.php new file mode 100644 index 00000000000..5c0f214284f --- /dev/null +++ b/src/Laravel/Metadata/ParameterValidationResourceMetadataCollectionFactory.php @@ -0,0 +1,157 @@ + + * + * 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\Laravel\Metadata; + +use ApiPlatform\Metadata\Parameter; +use ApiPlatform\Metadata\Parameters; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; +use Illuminate\Validation\Rule; +use Psr\Container\ContainerInterface; + +final class ParameterValidationResourceMetadataCollectionFactory implements ResourceMetadataCollectionFactoryInterface +{ + public function __construct( + private readonly ?ResourceMetadataCollectionFactoryInterface $decorated = null, + private readonly ?ContainerInterface $filterLocator = null, + ) { + } + + public function create(string $resourceClass): ResourceMetadataCollection + { + $resourceMetadataCollection = $this->decorated?->create($resourceClass) ?? new ResourceMetadataCollection($resourceClass); + + foreach ($resourceMetadataCollection as $i => $resource) { + $operations = $resource->getOperations(); + + foreach ($operations as $operationName => $operation) { + $parameters = $operation->getParameters() ?? new Parameters(); + foreach ($parameters as $key => $parameter) { + $parameters->add($key, $this->addSchemaValidation($parameter)); + } + + if (\count($parameters) > 0) { + $operations->add($operationName, $operation->withParameters($parameters)); + } + } + + $resourceMetadataCollection[$i] = $resource->withOperations($operations->sort()); + + if (!$graphQlOperations = $resource->getGraphQlOperations()) { + continue; + } + + foreach ($graphQlOperations as $operationName => $operation) { + $parameters = $operation->getParameters() ?? new Parameters(); + foreach ($operation->getParameters() ?? [] as $key => $parameter) { + $parameters->add($key, $this->addSchemaValidation($parameter)); + } + + if (\count($parameters) > 0) { + $graphQlOperations[$operationName] = $operation->withParameters($parameters); + } + } + + $resourceMetadataCollection[$i] = $resource->withGraphQlOperations($graphQlOperations); + } + + return $resourceMetadataCollection; + } + + private function addSchemaValidation(Parameter $parameter): Parameter + { + $schema = $parameter->getSchema(); + $required = $parameter->getRequired(); + $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]; + } + $assertions = []; + $allowEmptyValue = $openApi?->getAllowEmptyValue(); + if ($required || (false === $required && false === $allowEmptyValue)) { + $assertions[] = 'required'; + } + + if (true === $allowEmptyValue) { + $assertions[] = 'nullable'; + } + + if (isset($schema['exclusiveMinimum'])) { + $assertions[] = 'gt:'.$schema['exclusiveMinimum']; + } + + if (isset($schema['exclusiveMaximum'])) { + $assertions[] = 'lt:'.$schema['exclusiveMaximum']; + } + + if (isset($schema['minimum'])) { + $assertions[] = 'gte:'.$schema['minimum']; + } + + if (isset($schema['maximum'])) { + $assertions[] = 'lte:'.$schema['maximum']; + } + + if (isset($schema['pattern'])) { + $assertions[] = 'regex:'.$schema['pattern']; + } + + $minLength = isset($schema['minLength']); + $maxLength = isset($schema['maxLength']); + + if ($minLength && $maxLength) { + $assertions[] = \sprintf('between:%s,%s', $schema['minLength'], $schema['maxLength']); + } elseif ($minLength) { + $assertions[] = 'min:'.$schema['minLength']; + } elseif ($maxLength) { + $assertions[] = 'max:'.$schema['maxLength']; + } + + $minItems = isset($schema['minItems']); + $maxItems = isset($schema['maxItems']); + + if ($minItems && $maxItems) { + $assertions[] = \sprintf('between:%s,%s', $schema['minItems'], $schema['maxItems']); + } elseif ($minItems) { + $assertions[] = 'min:'.$schema['minItems']; + } elseif ($maxItems) { + $assertions[] = 'max:'.$schema['maxItems']; + } + + if (isset($schema['multipleOf'])) { + $assertions[] = 'multiple_of:'.$schema['multipleOf']; + } + + // if (isset($schema['enum'])) { + // $assertions[] = [Rule::enum($schema['enum'])]; + // } + + if (isset($schema['type']) && 'array' === $schema['type']) { + $assertions[] = 'array'; + } + + if (!$assertions) { + return $parameter; + } + + if (1 === \count($assertions)) { + return $parameter->withConstraints($assertions[0]); + } + + return $parameter->withConstraints($assertions); + } +} diff --git a/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php index df3be17fe24..a948b338808 100644 --- a/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php +++ b/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php @@ -15,33 +15,17 @@ use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\FilterInterface; -use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\JsonSchemaFilterInterface; use ApiPlatform\Metadata\OpenApiParameterFilterInterface; use ApiPlatform\Metadata\Parameter; use ApiPlatform\Metadata\Parameters; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; -use ApiPlatform\Metadata\QueryParameter; use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter; use ApiPlatform\Serializer\Filter\FilterInterface as SerializerFilterInterface; use Psr\Container\ContainerInterface; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; -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\Type; -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. @@ -67,8 +51,6 @@ public function create(string $resourceClass): ResourceMetadataCollection $properties = []; foreach ($this->propertyNameCollectionFactory->create($resourceClass) as $i => $property) { $propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $property); - if ('author' === $property) { - } if ($propertyMetadata->isReadable()) { $propertyNames[] = $property; $properties[$property] = $propertyMetadata; @@ -87,7 +69,7 @@ public function create(string $resourceClass): ResourceMetadataCollection $converted = $this->nameConverter?->denormalize($property) ?? $property; $propertyParameter = $this->setDefaults($converted, $parameter, $resourceClass, $properties); $priority = $propertyParameter->getPriority() ?? $internalPriority--; - $parameters->add($converted, $this->addFilterMetadata($propertyParameter->withPriority($priority)->withKey($converted))); + $parameters->add($converted, $propertyParameter->withPriority($priority)->withKey($converted)); } $parameters->remove($key, $parameter::class); @@ -107,12 +89,7 @@ public function create(string $resourceClass): ResourceMetadataCollection $parameter = $this->setDefaults($key, $parameter, $resourceClass, $properties); $priority = $parameter->getPriority() ?? $internalPriority--; - $parameters->add($key, $this->addFilterMetadata($parameter->withPriority($priority))); - } - - // As we deprecate the parameter validator, we declare a parameter for each filter transfering validation to the new system - if ($operation->getFilters() && 0 === $parameters->count()) { - $parameters = $this->addFilterValidation($operation); + $parameters->add($key, $parameter->withPriority($priority)); } if (\count($parameters) > 0) { @@ -222,119 +199,6 @@ private function setDefaults(string $key, Parameter $parameter, string $resource $parameter = $parameter->withOpenApi($openApi); } - $schema = $parameter->getSchema() ?? (($openApi = $parameter->getOpenApi()) ? $openApi->getSchema() : null); - - // 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() ?: null); - } - - return $parameter; - } - - private function addSchemaValidation(Parameter $parameter, ?array $schema = null, bool $required = false, ?OpenApiParameter $openApi = null): Parameter - { - $assertions = []; - - if ($required && false !== ($allowEmptyValue = $openApi?->getAllowEmptyValue())) { - $assertions[] = new NotNull(message: \sprintf('The parameter "%s" is required.', $parameter->getKey())); - } - - if (false === ($allowEmptyValue ?? $openApi?->getAllowEmptyValue())) { - $assertions[] = new NotBlank(allowNull: !$required); - } - - 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 (isset($schema['type']) && 'array' === $schema['type']) { - $assertions[] = new Type(type: 'array'); - } - - if (!$assertions) { - return $parameter; - } - - if (1 === \count($assertions)) { - return $parameter->withConstraints($assertions[0]); - } - - return $parameter->withConstraints($assertions); - } - - private function addFilterValidation(HttpOperation $operation): Parameters - { - $parameters = new Parameters(); - $internalPriority = -1; - - foreach ($operation->getFilters() as $filter) { - if (!$this->filterLocator->has($filter)) { - continue; - } - - $filter = $this->filterLocator->get($filter); - foreach ($filter->getDescription($operation->getClass()) as $parameterName => $definition) { - $key = $parameterName; - $required = $definition['required'] ?? false; - $schema = $definition['schema'] ?? null; - - $openApi = null; - if (isset($definition['openapi']) && $definition['openapi'] instanceof OpenApiParameter) { - $openApi = $definition['openapi']; - } - - // The query parameter validator forced this, lets maintain BC on filters - if (true === $required && !$openApi) { - $openApi = new OpenApiParameter(name: $key, in: 'query', allowEmptyValue: false); - } - - $parameters->add($key, $this->addSchemaValidation( - // we disable openapi and hydra on purpose as their docs comes from filters see the condition for addFilterValidation above - new QueryParameter(key: $key, property: $definition['property'] ?? null, priority: $internalPriority--, schema: $schema, openApi: false, hydra: false), - $schema, - $required, - $openApi - )); - } - } - - return $parameters; + return $this->addFilterMetadata($parameter); } } diff --git a/src/State/Util/ParameterParserTrait.php b/src/State/Util/ParameterParserTrait.php index 6db86bfa463..cadbbcb8eb7 100644 --- a/src/State/Util/ParameterParserTrait.php +++ b/src/State/Util/ParameterParserTrait.php @@ -46,6 +46,10 @@ private function extractParameterValues(Parameter $parameter, array $values): st { $accessors = null; $key = $parameter->getKey(); + if (null === $key) { + throw new \RuntimeException('A Parameter should have a key.'); + } + $parsedKey = explode('[:property]', $key); if (isset($parsedKey[0]) && isset($values[$parsedKey[0]])) { $key = $parsedKey[0]; diff --git a/src/Symfony/Bundle/Resources/config/validator/validator.xml b/src/Symfony/Bundle/Resources/config/validator/validator.xml index 2319cf0bd67..e22897eaf21 100644 --- a/src/Symfony/Bundle/Resources/config/validator/validator.xml +++ b/src/Symfony/Bundle/Resources/config/validator/validator.xml @@ -14,5 +14,11 @@ + + + + + + diff --git a/src/Validator/Metadata/Resource/Factory/ParameterValidationResourceMetadataCollectionFactory.php b/src/Validator/Metadata/Resource/Factory/ParameterValidationResourceMetadataCollectionFactory.php new file mode 100644 index 00000000000..84dfebe8ea4 --- /dev/null +++ b/src/Validator/Metadata/Resource/Factory/ParameterValidationResourceMetadataCollectionFactory.php @@ -0,0 +1,209 @@ + + * + * 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\Metadata\Resource\Factory; + +use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\Parameter; +use ApiPlatform\Metadata\Parameters; +use ApiPlatform\Metadata\QueryParameter; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; +use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter; +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\Type; +use Symfony\Component\Validator\Constraints\Unique; + +final class ParameterValidationResourceMetadataCollectionFactory implements ResourceMetadataCollectionFactoryInterface +{ + public function __construct( + private readonly ?ResourceMetadataCollectionFactoryInterface $decorated = null, + private readonly ?ContainerInterface $filterLocator = null, + ) { + } + + public function create(string $resourceClass): ResourceMetadataCollection + { + $resourceMetadataCollection = $this->decorated?->create($resourceClass) ?? new ResourceMetadataCollection($resourceClass); + + foreach ($resourceMetadataCollection as $i => $resource) { + $operations = $resource->getOperations(); + + foreach ($operations as $operationName => $operation) { + $parameters = $operation->getParameters() ?? new Parameters(); + foreach ($parameters as $key => $parameter) { + $parameters->add($key, $this->addSchemaValidation($parameter)); + } + + // As we deprecate the parameter validator, we declare a parameter for each filter transfering validation to the new system + if ($operation->getFilters() && 0 === $parameters->count()) { + $parameters = $this->addFilterValidation($operation); + } + + if (\count($parameters) > 0) { + $operations->add($operationName, $operation->withParameters($parameters)); + } + } + + $resourceMetadataCollection[$i] = $resource->withOperations($operations->sort()); + + if (!$graphQlOperations = $resource->getGraphQlOperations()) { + continue; + } + + foreach ($graphQlOperations as $operationName => $operation) { + $parameters = $operation->getParameters() ?? new Parameters(); + foreach ($operation->getParameters() ?? [] as $key => $parameter) { + $parameters->add($key, $this->addSchemaValidation($parameter)); + } + + if (\count($parameters) > 0) { + $graphQlOperations[$operationName] = $operation->withParameters($parameters); + } + } + + $resourceMetadataCollection[$i] = $resource->withGraphQlOperations($graphQlOperations); + } + + return $resourceMetadataCollection; + } + + private function addSchemaValidation(Parameter $parameter, ?array $schema = null, ?bool $required = null, ?OpenApiParameter $openApi = null): 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 = []; + + if ($required && false !== ($allowEmptyValue = $openApi?->getAllowEmptyValue())) { + $assertions[] = new NotNull(message: \sprintf('The parameter "%s" is required.', $parameter->getKey())); + } + + if (false === ($allowEmptyValue ?? $openApi?->getAllowEmptyValue())) { + $assertions[] = new NotBlank(allowNull: !$required); + } + + 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 (isset($schema['type']) && 'array' === $schema['type']) { + $assertions[] = new Type(type: 'array'); + } + + if (!$assertions) { + return $parameter; + } + + if (1 === \count($assertions)) { + return $parameter->withConstraints($assertions[0]); + } + + return $parameter->withConstraints($assertions); + } + + private function addFilterValidation(HttpOperation $operation): Parameters + { + $parameters = new Parameters(); + $internalPriority = -1; + + foreach ($operation->getFilters() as $filter) { + if (!$this->filterLocator->has($filter)) { + continue; + } + + $filter = $this->filterLocator->get($filter); + foreach ($filter->getDescription($operation->getClass()) as $parameterName => $definition) { + $key = $parameterName; + $required = $definition['required'] ?? false; + $schema = $definition['schema'] ?? null; + + $openApi = null; + if (isset($definition['openapi']) && $definition['openapi'] instanceof OpenApiParameter) { + $openApi = $definition['openapi']; + } + + // The query parameter validator forced this, lets maintain BC on filters + if (true === $required && !$openApi) { + $openApi = new OpenApiParameter(name: $key, in: 'query', allowEmptyValue: false); + } + + $parameters->add($key, $this->addSchemaValidation( + // we disable openapi and hydra on purpose as their docs comes from filters see the condition for addFilterValidation above + new QueryParameter(key: $key, property: $definition['property'] ?? null, priority: $internalPriority--, schema: $schema, openApi: false, hydra: false), + $schema, + $required, + $openApi + )); + } + } + + return $parameters; + } +} diff --git a/tests/Fixtures/TestBundle/Document/SearchFilterParameter.php b/tests/Fixtures/TestBundle/Document/SearchFilterParameter.php index c659fab8af4..83a66431f54 100644 --- a/tests/Fixtures/TestBundle/Document/SearchFilterParameter.php +++ b/tests/Fixtures/TestBundle/Document/SearchFilterParameter.php @@ -26,6 +26,7 @@ uriTemplate: 'search_filter_parameter{._format}', parameters: [ 'foo' => new QueryParameter(filter: 'app_odm_search_filter_via_parameter'), + 'fooAlias' => new QueryParameter(filter: 'app_odm_search_filter_via_parameter', property: 'foo'), 'order[:property]' => new QueryParameter(filter: 'app_odm_search_filter_via_parameter.order_filter'), 'searchPartial[:property]' => new QueryParameter(filter: 'app_odm_search_filter_partial'),