Skip to content

Commit 7518b39

Browse files
committed
feat(state): reject empty values cast to a scalar native type with 400
ValueCaster throws a Bad Request when an empty string is cast to a bool/int/float native type: an empty value cannot represent the type and must not reach the filter as a raw, untyped value (which caused driver-specific errors such as PostgreSQL rejecting '' for an integer). The exception is a new framework-agnostic ApiPlatform\Metadata\Exception\BadRequestException (implements HttpExceptionInterface, status 400); the Laravel ErrorRenderer now maps Metadata\Exception\HttpExceptionInterface like the Symfony ErrorListener already does. Non-empty uncastable values are still returned unchanged so constraint validation rejects them (422); parameters without castToNativeType keep the empty string, so e.g. a string filter can still match it.
1 parent 859c3e5 commit 7518b39

6 files changed

Lines changed: 81 additions & 44 deletions

File tree

src/Laravel/Exception/ErrorRenderer.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
use ApiPlatform\Laravel\ApiResource\Error;
1717
use ApiPlatform\Laravel\Controller\ApiPlatformController;
18+
use ApiPlatform\Metadata\Exception\HttpExceptionInterface;
1819
use ApiPlatform\Metadata\Exception\InvalidUriVariableException;
1920
use ApiPlatform\Metadata\Exception\ProblemExceptionInterface;
2021
use ApiPlatform\Metadata\Exception\StatusAwareExceptionInterface;
@@ -186,7 +187,7 @@ private function getStatusCode(?HttpOperation $apiOperation, ?HttpOperation $err
186187
return 403;
187188
}
188189

189-
if ($exception instanceof SymfonyHttpExceptionInterface) {
190+
if ($exception instanceof SymfonyHttpExceptionInterface || $exception instanceof HttpExceptionInterface) {
190191
return $exception->getStatusCode();
191192
}
192193

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Metadata\Exception;
15+
16+
/**
17+
* Framework-agnostic 400 Bad Request, mapped by both the Symfony and Laravel error handlers.
18+
*/
19+
class BadRequestException extends \RuntimeException implements ExceptionInterface, HttpExceptionInterface
20+
{
21+
public function getStatusCode(): int
22+
{
23+
return 400;
24+
}
25+
26+
/**
27+
* @return array<string, string>
28+
*/
29+
public function getHeaders(): array
30+
{
31+
return [];
32+
}
33+
}

src/State/Parameter/ValueCaster.php

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,12 @@
1313

1414
namespace ApiPlatform\State\Parameter;
1515

16+
use ApiPlatform\Metadata\Exception\BadRequestException;
17+
1618
/**
17-
* Caster returns the default value when a value can not be casted
18-
* This is used by parameters before they get validated by constraints
19-
* Therefore we do not need to throw exceptions, validation will just fail.
19+
* Caster returns the value unchanged when it can not be casted, so constraint validation can
20+
* reject it. An empty string is the exception: it can not represent a scalar native type, so we
21+
* throw a Bad Request (400) rather than letting it reach the filter as a raw, untyped value.
2022
*
2123
* @internal
2224
*/
@@ -30,7 +32,8 @@ public static function toBool(mixed $v): mixed
3032

3133
return match (strtolower($v)) {
3234
'1', 'true' => true,
33-
'0', 'false', '' => false,
35+
'0', 'false' => false,
36+
'' => throw new BadRequestException('An empty value cannot be cast to a boolean.'),
3437
default => $v,
3538
};
3639
}
@@ -42,7 +45,7 @@ public static function toInt(mixed $v): mixed
4245
}
4346

4447
if ('' === $v) {
45-
return 0;
48+
throw new BadRequestException('An empty value cannot be cast to an integer.');
4649
}
4750

4851
$value = filter_var($v, \FILTER_VALIDATE_INT);
@@ -57,7 +60,7 @@ public static function toFloat(mixed $v): mixed
5760
}
5861

5962
if ('' === $v) {
60-
return 0.0;
63+
throw new BadRequestException('An empty value cannot be cast to a float.');
6164
}
6265

6366
$value = filter_var($v, \FILTER_VALIDATE_FLOAT);

src/State/Tests/Parameter/ValueCasterTest.php

Lines changed: 29 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
namespace ApiPlatform\State\Tests\Parameter;
1515

16+
use ApiPlatform\Metadata\Exception\BadRequestException;
1617
use ApiPlatform\State\Parameter\ValueCaster;
1718
use PHPUnit\Framework\Attributes\DataProvider;
1819
use PHPUnit\Framework\TestCase;
@@ -25,6 +26,19 @@ public function testToBool(mixed $value, mixed $expected): void
2526
$this->assertSame($expected, ValueCaster::toBool($value));
2627
}
2728

29+
public static function boolProvider(): \Generator
30+
{
31+
yield 'true string' => ['true', true];
32+
yield 'numeric 1' => ['1', true];
33+
yield 'false string' => ['false', false];
34+
yield 'numeric 0' => ['0', false];
35+
// Unrecognized values (including "null") are returned untouched so constraint validation
36+
// rejects them.
37+
yield 'invalid string' => ['string', 'string'];
38+
yield 'null string is not cast' => ['null', 'null'];
39+
yield 'non-string passthrough' => [true, true];
40+
}
41+
2842
#[DataProvider('intProvider')]
2943
public function testToInt(mixed $value, mixed $expected): void
3044
{
@@ -34,7 +48,6 @@ public function testToInt(mixed $value, mixed $expected): void
3448
public static function intProvider(): \Generator
3549
{
3650
yield 'integer string' => ['10', 10];
37-
yield 'empty string' => ['', 0];
3851
yield 'invalid string' => ['string', 'string'];
3952
yield 'null string is not cast' => ['null', 'null'];
4053
yield 'int passthrough' => [10, 10];
@@ -49,25 +62,26 @@ public function testToFloat(mixed $value, mixed $expected): void
4962
public static function floatProvider(): \Generator
5063
{
5164
yield 'float string' => ['1.5', 1.5];
52-
yield 'empty string' => ['', 0.0];
5365
yield 'invalid string' => ['string', 'string'];
5466
yield 'null string is not cast' => ['null', 'null'];
5567
yield 'float passthrough' => [1.5, 1.5];
5668
}
5769

58-
public static function boolProvider(): \Generator
70+
/**
71+
* An empty string cannot represent a scalar native type, so the caster rejects it with a
72+
* Bad Request rather than leaving a raw value for the filter.
73+
*/
74+
#[DataProvider('emptyCasterProvider')]
75+
public function testEmptyValueThrowsBadRequest(callable $caster): void
5976
{
60-
yield 'true string' => ['true', true];
61-
yield 'numeric 1' => ['1', true];
62-
yield 'false string' => ['false', false];
63-
yield 'numeric 0' => ['0', false];
64-
// An empty value casts to false deterministically across drivers, instead of
65-
// reaching the database as a raw string that each driver coerces differently.
66-
yield 'empty string' => ['', false];
67-
// Genuinely invalid values (including "null") are returned untouched so constraint
68-
// validation rejects them; "null" is not a supported boolean input.
69-
yield 'invalid string' => ['string', 'string'];
70-
yield 'null string is not cast' => ['null', 'null'];
71-
yield 'non-string passthrough' => [true, true];
77+
$this->expectException(BadRequestException::class);
78+
$caster('');
79+
}
80+
81+
public static function emptyCasterProvider(): \Generator
82+
{
83+
yield 'toBool' => [ValueCaster::toBool(...)];
84+
yield 'toInt' => [ValueCaster::toInt(...)];
85+
yield 'toFloat' => [ValueCaster::toFloat(...)];
7286
}
7387
}

tests/Functional/Parameters/BooleanFilterTest.php

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -77,24 +77,15 @@ public static function booleanFilterScenariosProvider(): \Generator
7777
}
7878

7979
/**
80-
* With the canonical ExactFilter, an empty boolean value casts to false (deterministically
81-
* across drivers), so it matches the inactive rows — unlike the deprecated BooleanFilter
82-
* which skipped filtering (returning all rows, covered by the legacy regression suite).
80+
* An empty value cannot be cast to the boolean native type, so the caster rejects it with a
81+
* Bad Request before the filter runs.
8382
*/
8483
#[DataProvider('booleanFilterEmptyScenariosProvider')]
8584
public function testBooleanFilterWithEmptyValues(string $url): void
8685
{
87-
$response = self::createClient()->request('GET', $url);
88-
$this->assertResponseIsSuccessful();
89-
90-
$responseData = $response->toArray();
91-
$filteredItems = $responseData['hydra:member'];
92-
93-
$this->assertCount(1, $filteredItems, \sprintf('Expected one inactive item for URL %s', $url));
86+
self::createClient()->request('GET', $url);
9487

95-
foreach ($filteredItems as $item) {
96-
$this->assertFalse($item['active'], 'Expected an empty boolean value to cast to false');
97-
}
88+
$this->assertResponseStatusCodeSame(400);
9889
}
9990

10091
public static function booleanFilterEmptyScenariosProvider(): \Generator

tests/Functional/Parameters/NumericFilterTest.php

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -67,20 +67,15 @@ public static function rangeFilterScenariosProvider(): \Generator
6767
}
6868

6969
/**
70-
* With the canonical ExactFilter, an empty value casts to 0/0.0 (deterministically across
71-
* drivers) and matches no row in this fixture — unlike the deprecated NumericFilter which
72-
* skipped filtering (returning all rows, covered by the legacy regression suite).
70+
* An empty value cannot be cast to the int/float native type, so the caster rejects it with a
71+
* Bad Request before the filter runs.
7372
*/
7473
#[DataProvider('emptyScenariosProvider')]
7574
public function testRangeFilterWithEmptyValues(string $url): void
7675
{
77-
$response = self::createClient()->request('GET', $url);
78-
$this->assertResponseIsSuccessful();
79-
80-
$responseData = $response->toArray();
81-
$filteredItems = $responseData['hydra:member'];
76+
self::createClient()->request('GET', $url);
8277

83-
$this->assertCount(0, $filteredItems, \sprintf('Expected no item for URL %s', $url));
78+
$this->assertResponseStatusCodeSame(400);
8479
}
8580

8681
public static function emptyScenariosProvider(): \Generator

0 commit comments

Comments
 (0)