Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions src/Doctrine/Common/Filter/EmptyValueParameterTrait.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* 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;
}
}
6 changes: 6 additions & 0 deletions src/Doctrine/Odm/Filter/ExactFilter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -32,6 +33,7 @@
final class ExactFilter implements FilterInterface, OpenApiParameterFilterInterface, ManagerRegistryAwareInterface
{
use BackwardCompatibleFilterDescriptionTrait;
use EmptyValueParameterTrait;
use ManagerRegistryAwareTrait;
use NestedPropertyHelperTrait;
use OpenApiFilterTrait;
Expand All @@ -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()));
}
Expand Down
2 changes: 1 addition & 1 deletion src/Doctrine/Odm/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions src/Doctrine/Orm/Filter/ExactFilter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -28,6 +29,7 @@
final class ExactFilter implements FilterInterface, OpenApiParameterFilterInterface
{
use BackwardCompatibleFilterDescriptionTrait;
use EmptyValueParameterTrait;
use NestedPropertyHelperTrait;
use OpenApiFilterTrait;

Expand All @@ -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()));
}
Expand Down
2 changes: 1 addition & 1 deletion src/Doctrine/Orm/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
14 changes: 1 addition & 13 deletions src/State/Parameter/ValueCaster.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand All @@ -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,
};
}
Expand All @@ -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;
Expand All @@ -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;
Expand Down
10 changes: 2 additions & 8 deletions src/State/Tests/Parameter/ValueCasterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* 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 [];
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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),
Expand Down
8 changes: 8 additions & 0 deletions tests/Fixtures/TestBundle/Entity/FilteredNumericParameter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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),
Expand Down
12 changes: 6 additions & 6 deletions tests/Functional/Parameters/BooleanFilterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
61 changes: 61 additions & 0 deletions tests/Functional/Parameters/EmptyScalarParameterTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* 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);
}
}
6 changes: 6 additions & 0 deletions tests/Functional/Parameters/ExactFilterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading