From d7c190ce4cd73396dc426edc51431af25d5e26e1 Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 12 Oct 2026 16:01:07 +0200 Subject: [PATCH 1/2] fix(state): restore 422 for empty scalar params Fixes #8641 --- .../Filter/EmptyValueParameterTrait.php | 46 ++++++++++++++ src/Doctrine/Odm/Filter/ExactFilter.php | 6 ++ src/Doctrine/Orm/Filter/ExactFilter.php | 6 ++ src/State/Parameter/ValueCaster.php | 14 +---- src/State/Tests/Parameter/ValueCasterTest.php | 10 +-- .../ChoiceConstrainedScalarParameter.php | 56 +++++++++++++++++ .../Document/FilteredNumericParameter.php | 8 +++ .../Entity/FilteredNumericParameter.php | 8 +++ .../Parameters/BooleanFilterTest.php | 12 ++-- .../Parameters/EmptyScalarParameterTest.php | 61 +++++++++++++++++++ .../Functional/Parameters/ExactFilterTest.php | 6 ++ .../Parameters/NumericFilterTest.php | 21 ++++--- 12 files changed, 217 insertions(+), 37 deletions(-) create mode 100644 src/Doctrine/Common/Filter/EmptyValueParameterTrait.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/ChoiceConstrainedScalarParameter/ChoiceConstrainedScalarParameter.php create mode 100644 tests/Functional/Parameters/EmptyScalarParameterTest.php diff --git a/src/Doctrine/Common/Filter/EmptyValueParameterTrait.php b/src/Doctrine/Common/Filter/EmptyValueParameterTrait.php new file mode 100644 index 00000000000..1cb7533a1ad --- /dev/null +++ b/src/Doctrine/Common/Filter/EmptyValueParameterTrait.php @@ -0,0 +1,46 @@ + + * + * 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\Doctrine\Common\Filter; + +use ApiPlatform\Metadata\Parameter; +use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\Type\CollectionType; +use Symfony\Component\TypeInfo\TypeIdentifier; + +/** @internal */ +trait EmptyValueParameterTrait +{ + private function ignoreEmptyValue(Parameter $parameter, mixed &$value): bool + { + if ('' !== $value && !(\is_array($value) && \in_array('', $value, true))) { + return false; + } + + $nativeType = $parameter->getNativeType(); + $isStringLike = static fn (Type $t): bool => $t->isIdentifiedBy(TypeIdentifier::STRING) + || ($t instanceof CollectionType && $t->getCollectionValueType()->isIdentifiedBy(TypeIdentifier::STRING)); + + if (null === $nativeType || $nativeType->isSatisfiedBy($isStringLike)) { + return false; + } + + if (\is_array($value)) { + $value = array_values(array_filter($value, static fn (mixed $v): bool => '' !== $v)); + + return [] === $value; + } + + return true; + } +} diff --git a/src/Doctrine/Odm/Filter/ExactFilter.php b/src/Doctrine/Odm/Filter/ExactFilter.php index 16c56cfb38e..6c1c1545d27 100644 --- a/src/Doctrine/Odm/Filter/ExactFilter.php +++ b/src/Doctrine/Odm/Filter/ExactFilter.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Doctrine\Odm\Filter; +use ApiPlatform\Doctrine\Common\Filter\EmptyValueParameterTrait; use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface; use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareTrait; use ApiPlatform\Doctrine\Common\Filter\OpenApiFilterTrait; @@ -32,6 +33,7 @@ final class ExactFilter implements FilterInterface, OpenApiParameterFilterInterface, ManagerRegistryAwareInterface { use BackwardCompatibleFilterDescriptionTrait; + use EmptyValueParameterTrait; use ManagerRegistryAwareTrait; use NestedPropertyHelperTrait; use OpenApiFilterTrait; @@ -50,6 +52,10 @@ public function apply(Builder $aggregationBuilder, string $resourceClass, ?Opera return; } + if ($this->ignoreEmptyValue($parameter, $value)) { + return; + } + if (null === $parameter->getProperty()) { throw new InvalidArgumentException(\sprintf('The filter parameter with key "%s" must specify a property. Please provide the property explicitly.', $parameter->getKey())); } diff --git a/src/Doctrine/Orm/Filter/ExactFilter.php b/src/Doctrine/Orm/Filter/ExactFilter.php index f7fbf4cf54b..e9e7b92c4b5 100644 --- a/src/Doctrine/Orm/Filter/ExactFilter.php +++ b/src/Doctrine/Orm/Filter/ExactFilter.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Doctrine\Orm\Filter; +use ApiPlatform\Doctrine\Common\Filter\EmptyValueParameterTrait; use ApiPlatform\Doctrine\Common\Filter\OpenApiFilterTrait; use ApiPlatform\Doctrine\Orm\NestedPropertyHelperTrait; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; @@ -28,6 +29,7 @@ final class ExactFilter implements FilterInterface, OpenApiParameterFilterInterface { use BackwardCompatibleFilterDescriptionTrait; + use EmptyValueParameterTrait; use NestedPropertyHelperTrait; use OpenApiFilterTrait; @@ -41,6 +43,10 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q return; } + if ($this->ignoreEmptyValue($parameter, $value)) { + return; + } + if (null === $parameter->getProperty()) { throw new InvalidArgumentException(\sprintf('The filter parameter with key "%s" must specify a property. Please provide the property explicitly.', $parameter->getKey())); } diff --git a/src/State/Parameter/ValueCaster.php b/src/State/Parameter/ValueCaster.php index 8ba295eebf2..5301a7456e3 100644 --- a/src/State/Parameter/ValueCaster.php +++ b/src/State/Parameter/ValueCaster.php @@ -13,12 +13,9 @@ namespace ApiPlatform\State\Parameter; -use ApiPlatform\Metadata\Exception\BadRequestException; - /** * Caster returns the value unchanged when it can not be casted, so constraint validation can - * reject it. An empty string is the exception: it can not represent a scalar native type, so we - * throw a Bad Request (400) rather than letting it reach the filter as a raw, untyped value. + * reject it. Therefore we do not need to throw exceptions, validation will just fail. * * @internal */ @@ -33,7 +30,6 @@ public static function toBool(mixed $v): mixed return match (strtolower($v)) { '1', 'true' => true, '0', 'false' => false, - '' => throw new BadRequestException('An empty value cannot be cast to a boolean.'), default => $v, }; } @@ -44,10 +40,6 @@ public static function toInt(mixed $v): mixed return $v; } - if ('' === $v) { - throw new BadRequestException('An empty value cannot be cast to an integer.'); - } - $value = filter_var($v, \FILTER_VALIDATE_INT); return false === $value ? $v : $value; @@ -59,10 +51,6 @@ public static function toFloat(mixed $v): mixed return $v; } - if ('' === $v) { - throw new BadRequestException('An empty value cannot be cast to a float.'); - } - $value = filter_var($v, \FILTER_VALIDATE_FLOAT); return false === $value ? $v : $value; diff --git a/src/State/Tests/Parameter/ValueCasterTest.php b/src/State/Tests/Parameter/ValueCasterTest.php index 481cd30e7e5..a9f33045c24 100644 --- a/src/State/Tests/Parameter/ValueCasterTest.php +++ b/src/State/Tests/Parameter/ValueCasterTest.php @@ -13,7 +13,6 @@ namespace ApiPlatform\State\Tests\Parameter; -use ApiPlatform\Metadata\Exception\BadRequestException; use ApiPlatform\State\Parameter\ValueCaster; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; @@ -67,15 +66,10 @@ public static function floatProvider(): \Generator yield 'float passthrough' => [1.5, 1.5]; } - /** - * An empty string cannot represent a scalar native type, so the caster rejects it with a - * Bad Request rather than leaving a raw value for the filter. - */ #[DataProvider('emptyCasterProvider')] - public function testEmptyValueThrowsBadRequest(callable $caster): void + public function testEmptyValueIsReturnedUnchanged(callable $caster): void { - $this->expectException(BadRequestException::class); - $caster(''); + $this->assertSame('', $caster('')); } public static function emptyCasterProvider(): \Generator diff --git a/tests/Fixtures/TestBundle/ApiResource/ChoiceConstrainedScalarParameter/ChoiceConstrainedScalarParameter.php b/tests/Fixtures/TestBundle/ApiResource/ChoiceConstrainedScalarParameter/ChoiceConstrainedScalarParameter.php new file mode 100644 index 00000000000..2bba96efc67 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/ChoiceConstrainedScalarParameter/ChoiceConstrainedScalarParameter.php @@ -0,0 +1,56 @@ + + * + * 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\Fixtures\TestBundle\ApiResource\ChoiceConstrainedScalarParameter; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\QueryParameter; +use Symfony\Component\Validator\Constraints as Assert; + +#[ApiResource( + operations: [ + new GetCollection( + uriTemplate: '/choice_constrained_scalar_parameters', + parameters: [ + 'days' => new QueryParameter( + schema: ['type' => 'integer', 'enum' => self::DAYS], + constraints: [new Assert\Choice(choices: self::DAYS)], + castToNativeType: true, + ), + 'ratio' => new QueryParameter( + schema: ['type' => 'number', 'enum' => self::RATIOS], + constraints: [new Assert\Choice(choices: self::RATIOS)], + castToNativeType: true, + ), + 'active' => new QueryParameter( + schema: ['type' => 'boolean'], + constraints: [new Assert\Choice(choices: [true, false])], + castToNativeType: true, + ), + ], + provider: [self::class, 'provide'], + ), + ] +)] +class ChoiceConstrainedScalarParameter +{ + public const DAYS = [7, 30, 90]; + public const RATIOS = [0.5, 1.5]; + + public static function provide(Operation $operation): array + { + return []; + } +} diff --git a/tests/Fixtures/TestBundle/Document/FilteredNumericParameter.php b/tests/Fixtures/TestBundle/Document/FilteredNumericParameter.php index 363d8ee2da6..86f46e4fa8c 100644 --- a/tests/Fixtures/TestBundle/Document/FilteredNumericParameter.php +++ b/tests/Fixtures/TestBundle/Document/FilteredNumericParameter.php @@ -19,6 +19,8 @@ use ApiPlatform\Metadata\QueryParameter; use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; use Symfony\Component\TypeInfo\Type\BuiltinType; +use Symfony\Component\TypeInfo\Type\CollectionType; +use Symfony\Component\TypeInfo\Type\GenericType; use Symfony\Component\TypeInfo\TypeIdentifier; #[ApiResource] @@ -36,6 +38,12 @@ nativeType: new BuiltinType(TypeIdentifier::INT), castToNativeType: true, ), + 'quantities' => new QueryParameter( + filter: new ExactFilter(), + property: 'quantity', + nativeType: new CollectionType(new GenericType(new BuiltinType(TypeIdentifier::ARRAY), new BuiltinType(TypeIdentifier::INT)), true), + castToNativeType: true, + ), 'ratio' => new QueryParameter( filter: new ExactFilter(), nativeType: new BuiltinType(TypeIdentifier::FLOAT), diff --git a/tests/Fixtures/TestBundle/Entity/FilteredNumericParameter.php b/tests/Fixtures/TestBundle/Entity/FilteredNumericParameter.php index 1a1a68b94b8..44989d839c5 100644 --- a/tests/Fixtures/TestBundle/Entity/FilteredNumericParameter.php +++ b/tests/Fixtures/TestBundle/Entity/FilteredNumericParameter.php @@ -19,6 +19,8 @@ use ApiPlatform\Metadata\QueryParameter; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\TypeInfo\Type\BuiltinType; +use Symfony\Component\TypeInfo\Type\CollectionType; +use Symfony\Component\TypeInfo\Type\GenericType; use Symfony\Component\TypeInfo\TypeIdentifier; #[ApiResource] @@ -36,6 +38,12 @@ nativeType: new BuiltinType(TypeIdentifier::INT), castToNativeType: true, ), + 'quantities' => new QueryParameter( + filter: new ExactFilter(), + property: 'quantity', + nativeType: new CollectionType(new GenericType(new BuiltinType(TypeIdentifier::ARRAY), new BuiltinType(TypeIdentifier::INT)), true), + castToNativeType: true, + ), 'ratio' => new QueryParameter( filter: new ExactFilter(), nativeType: new BuiltinType(TypeIdentifier::FLOAT), diff --git a/tests/Functional/Parameters/BooleanFilterTest.php b/tests/Functional/Parameters/BooleanFilterTest.php index a5dd382ac53..21779c39fde 100644 --- a/tests/Functional/Parameters/BooleanFilterTest.php +++ b/tests/Functional/Parameters/BooleanFilterTest.php @@ -76,16 +76,16 @@ public static function booleanFilterScenariosProvider(): \Generator yield 'enabled_alias_numeric_0' => ['/filtered_boolean_parameters?enabled=0', 1, false]; } - /** - * An empty value cannot be cast to the boolean native type, so the caster rejects it with a - * Bad Request before the filter runs. - */ #[DataProvider('booleanFilterEmptyScenariosProvider')] public function testBooleanFilterWithEmptyValues(string $url): void { - self::createClient()->request('GET', $url); + $response = self::createClient()->request('GET', $url); + $this->assertResponseIsSuccessful(); + + $filteredItems = $response->toArray()['hydra:member']; - $this->assertResponseStatusCodeSame(400); + $expectedItemCount = 3; + $this->assertCount($expectedItemCount, $filteredItems, \sprintf('Expected %d items for URL %s', $expectedItemCount, $url)); } public static function booleanFilterEmptyScenariosProvider(): \Generator diff --git a/tests/Functional/Parameters/EmptyScalarParameterTest.php b/tests/Functional/Parameters/EmptyScalarParameterTest.php new file mode 100644 index 00000000000..d19c2067bac --- /dev/null +++ b/tests/Functional/Parameters/EmptyScalarParameterTest.php @@ -0,0 +1,61 @@ + + * + * 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; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\ChoiceConstrainedScalarParameter\ChoiceConstrainedScalarParameter; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use PHPUnit\Framework\Attributes\DataProvider; + +final class EmptyScalarParameterTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ChoiceConstrainedScalarParameter::class]; + } + + #[DataProvider('provideScalarParameters')] + public function testEmptyValueIsRejectedByConstraints(string $parameter): void + { + $response = self::createClient()->request('GET', '/choice_constrained_scalar_parameters?'.$parameter.'='); + + $this->assertResponseStatusCodeSame(422); + $this->assertArraySubset([ + 'violations' => [ + ['propertyPath' => $parameter, 'message' => 'The value you selected is not a valid choice.'], + ], + ], $response->toArray(false)); + } + + public static function provideScalarParameters(): \Generator + { + yield 'integer' => ['days']; + yield 'float' => ['ratio']; + yield 'boolean' => ['active']; + } + + public function testUncastableValueIsRejectedByConstraints(): void + { + self::createClient()->request('GET', '/choice_constrained_scalar_parameters?days=foo'); + + $this->assertResponseStatusCodeSame(422); + } +} diff --git a/tests/Functional/Parameters/ExactFilterTest.php b/tests/Functional/Parameters/ExactFilterTest.php index 534163f081e..1f2b96368a5 100644 --- a/tests/Functional/Parameters/ExactFilterTest.php +++ b/tests/Functional/Parameters/ExactFilterTest.php @@ -87,6 +87,12 @@ public static function exactSearchFilterProvider(): \Generator 0, [], ]; + + yield 'filter by an empty string-typed name still filters' => [ + '/chickens?name=', + 0, + [], + ]; } public function testExactSearchFilterByCoopId(): void diff --git a/tests/Functional/Parameters/NumericFilterTest.php b/tests/Functional/Parameters/NumericFilterTest.php index bcae5ab1930..5fcb92d8bd6 100644 --- a/tests/Functional/Parameters/NumericFilterTest.php +++ b/tests/Functional/Parameters/NumericFilterTest.php @@ -66,23 +66,24 @@ public static function rangeFilterScenariosProvider(): \Generator yield 'amount_alias_int_equal' => ['/filtered_numeric_parameters?amount=20', 2]; } - /** - * An empty value cannot be cast to the int/float native type, so the caster rejects it with a - * Bad Request before the filter runs. - */ #[DataProvider('emptyScenariosProvider')] - public function testRangeFilterWithEmptyValues(string $url): void + public function testRangeFilterWithEmptyValues(string $url, int $expectedCount): void { - self::createClient()->request('GET', $url); + $response = self::createClient()->request('GET', $url); + $this->assertResponseIsSuccessful(); + + $filteredItems = $response->toArray()['hydra:member']; - $this->assertResponseStatusCodeSame(400); + $this->assertCount($expectedCount, $filteredItems, \sprintf('Expected %d items for URL %s', $expectedCount, $url)); } public static function emptyScenariosProvider(): \Generator { - yield 'quantity_int_empty_value' => ['/filtered_numeric_parameters?quantity=']; - yield 'ratio_float_empty_value' => ['/filtered_numeric_parameters?ratio=']; - yield 'amount_alias_int_empty_value' => ['/filtered_numeric_parameters?amount=']; + yield 'quantity_int_empty_value' => ['/filtered_numeric_parameters?quantity=', 4]; + yield 'ratio_float_empty_value' => ['/filtered_numeric_parameters?ratio=', 4]; + yield 'amount_alias_int_empty_value' => ['/filtered_numeric_parameters?amount=', 4]; + yield 'quantities_list_only_empty_value' => ['/filtered_numeric_parameters?quantities[]=', 4]; + yield 'quantities_list_value_and_empty_value' => ['/filtered_numeric_parameters?quantities[]=20&quantities[]=', 2]; } /** From bf06187077ae4b695357b1ccf32004b8eed39653 Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 12 Oct 2026 16:47:24 +0200 Subject: [PATCH 2/2] chore(doctrine): require doctrine-common ^4.4.5 --- src/Doctrine/Odm/composer.json | 2 +- src/Doctrine/Orm/composer.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Doctrine/Odm/composer.json b/src/Doctrine/Odm/composer.json index 27b904bdfc3..0389b571ee3 100644 --- a/src/Doctrine/Odm/composer.json +++ b/src/Doctrine/Odm/composer.json @@ -25,7 +25,7 @@ ], "require": { "php": ">=8.2", - "api-platform/doctrine-common": "^4.4", + "api-platform/doctrine-common": "^4.4.5", "api-platform/metadata": "^4.4", "api-platform/serializer": "^4.4", "api-platform/state": "^4.4", diff --git a/src/Doctrine/Orm/composer.json b/src/Doctrine/Orm/composer.json index e49f2014b31..2cfbc2eab48 100644 --- a/src/Doctrine/Orm/composer.json +++ b/src/Doctrine/Orm/composer.json @@ -24,7 +24,7 @@ ], "require": { "php": ">=8.2", - "api-platform/doctrine-common": "^4.4", + "api-platform/doctrine-common": "^4.4.5", "api-platform/metadata": "^4.4", "api-platform/serializer": "^4.4", "api-platform/state": "^4.4",