Skip to content

Commit b41ed3f

Browse files
vincentchalamonsoyuka
authored andcommitted
feat(state): cast parameter values to validate with the Type constraint
1 parent e40bb19 commit b41ed3f

File tree

9 files changed

+265
-23
lines changed

9 files changed

+265
-23
lines changed

src/Laravel/workbench/app/Models/Book.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@
3737
use Illuminate\Database\Eloquent\Factories\HasFactory;
3838
use Illuminate\Database\Eloquent\Model;
3939
use Illuminate\Database\Eloquent\Relations\BelongsTo;
40+
use Symfony\Component\TypeInfo\Type\BuiltinType;
41+
use Symfony\Component\TypeInfo\TypeIdentifier;
4042
use Workbench\App\Http\Requests\BookFormRequest;
4143

4244
#[ApiResource(
@@ -79,7 +81,7 @@
7981
property: 'name'
8082
)]
8183
#[QueryParameter(key: 'properties', filter: PropertyFilter::class)]
82-
#[QueryParameter(key: 'published', filter: BooleanFilter::class)]
84+
#[QueryParameter(key: 'published', filter: BooleanFilter::class, nativeType: new BuiltinType(TypeIdentifier::BOOL))]
8385
class Book extends Model
8486
{
8587
use HasFactory;

src/Metadata/Parameter.php

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,12 @@ abstract class Parameter
2727
* @param (array<string, mixed>&array{type?: string, default?: string})|null $schema
2828
* @param array<string, mixed> $extraProperties
2929
* @param ParameterProviderInterface|callable|string|null $provider
30-
* @param list<string> $properties a list of properties this parameter applies to (works with the :property placeholder)
30+
* @param list<string> $properties a list of properties this parameter applies to (works with the :property placeholder)
3131
* @param FilterInterface|string|null $filter
32-
* @param mixed $constraints an array of Symfony constraints, or an array of Laravel rules
33-
* @param Type $nativeType the PHP native type, we cast values to an array if its a CollectionType, if not and it's an array with a single value we use it (eg: HTTP Header)
32+
* @param mixed $constraints an array of Symfony constraints, or an array of Laravel rules
33+
* @param Type $nativeType the PHP native type, we cast values to an array if its a CollectionType, if not and it's an array with a single value we use it (eg: HTTP Header)
34+
* @param ?bool $castToNativeType whether API Platform should cast your parameter to the nativeType declared
35+
* @param ?callable $castFn the closure used to cast your parameter, this gets called only when $castToNativeType is set
3436
*/
3537
public function __construct(
3638
protected ?string $key = null,
@@ -51,6 +53,8 @@ public function __construct(
5153
protected array|string|null $filterContext = null,
5254
protected ?Type $nativeType = null,
5355
protected ?bool $castToArray = null,
56+
protected ?bool $castToNativeType = null,
57+
protected ?callable $castFn = null,
5458
) {
5559
}
5660

@@ -332,4 +336,30 @@ public function withCastToArray(bool $castToArray): self
332336

333337
return $self;
334338
}
339+
340+
public function getCastToNativeType(): ?bool
341+
{
342+
return $this->castToNativeType;
343+
}
344+
345+
public function withCastToNativeType(bool $castToNativeType): self
346+
{
347+
$self = clone $this;
348+
$self->castToNativeType = $castToNativeType;
349+
350+
return $self;
351+
}
352+
353+
public function getCastFn(): ?callable
354+
{
355+
return $this->castFn;
356+
}
357+
358+
public function withCastFn(callable $castFn): self
359+
{
360+
$self = clone $this;
361+
$self->castFn = $castFn;
362+
363+
return $self;
364+
}
335365
}

src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,13 @@
2828
use ApiPlatform\Metadata\Resource\ResourceMetadataCollection;
2929
use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter;
3030
use ApiPlatform\Serializer\Filter\FilterInterface as SerializerFilterInterface;
31+
use ApiPlatform\State\Parameter\ValueCaster;
3132
use ApiPlatform\State\Util\StateOptionsTrait;
3233
use Psr\Container\ContainerInterface;
3334
use Psr\Log\LoggerInterface;
3435
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
3536
use Symfony\Component\TypeInfo\Type;
37+
use Symfony\Component\TypeInfo\TypeIdentifier;
3638

3739
/**
3840
* Prepares Parameters documentation by reading its filter details and declaring an OpenApi parameter.
@@ -158,11 +160,27 @@ private function getDefaultParameters(Operation $operation, string $resourceClas
158160
$parameter = $parameter->withNativeType(Type::string());
159161
} elseif ('boolean' === ($parameter->getSchema()['type'] ?? null)) {
160162
$parameter = $parameter->withNativeType(Type::bool());
163+
} elseif ('integer' === ($parameter->getSchema()['type'] ?? null)) {
164+
$parameter = $parameter->withNativeType(Type::int());
165+
} elseif ('number' === ($parameter->getSchema()['type'] ?? null)) {
166+
$parameter = $parameter->withNativeType(Type::float());
161167
} else {
162168
$parameter = $parameter->withNativeType(Type::union(Type::string(), Type::list(Type::string())));
163169
}
164170
}
165171

172+
if ($parameter->getCastToNativeType() && null === $parameter->getCastFn() && ($nativeType = $parameter->getNativeType())) {
173+
if ($nativeType->isIdentifiedBy(TypeIdentifier::BOOL)) {
174+
$parameter = $parameter->withCastFn([ValueCaster::class, 'toBool']);
175+
}
176+
if ($nativeType->isIdentifiedBy(TypeIdentifier::INT)) {
177+
$parameter = $parameter->withCastFn([ValueCaster::class, 'toInt']);
178+
}
179+
if ($nativeType->isIdentifiedBy(TypeIdentifier::FLOAT)) {
180+
$parameter = $parameter->withCastFn([ValueCaster::class, 'toFloat']);
181+
}
182+
}
183+
166184
$priority = $parameter->getPriority() ?? $internalPriority--;
167185
$parameters->add($key, $parameter->withPriority($priority));
168186
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
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\State\Parameter;
15+
16+
/**
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.
20+
*
21+
* @internal
22+
*/
23+
final class ValueCaster
24+
{
25+
public static function toBool(mixed $v): mixed
26+
{
27+
if (!\is_string($v)) {
28+
return $v;
29+
}
30+
31+
return match (strtolower($v)) {
32+
'1', 'true' => true,
33+
'0', 'false' => false,
34+
default => $v,
35+
};
36+
}
37+
38+
public static function toInt(mixed $v): mixed
39+
{
40+
if (\is_int($v)) {
41+
return $v;
42+
}
43+
44+
$value = filter_var($v, \FILTER_VALIDATE_INT);
45+
46+
return false === $value ? $v : $value;
47+
}
48+
49+
public static function toFloat(mixed $v): mixed
50+
{
51+
if (\is_float($v)) {
52+
return $v;
53+
}
54+
55+
$value = filter_var($v, \FILTER_VALIDATE_FLOAT);
56+
57+
return false === $value ? $v : $value;
58+
}
59+
}

src/State/Util/ParameterParserTrait.php

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ private function getParameterValues(Parameter $parameter, ?Request $request, arr
4545
*
4646
* @return array<mixed, mixed>|ParameterNotFound|array
4747
*/
48-
private function extractParameterValues(Parameter $parameter, array $values): string|ParameterNotFound|array
48+
private function extractParameterValues(Parameter $parameter, array $values): string|ParameterNotFound|array|bool
4949
{
5050
$accessors = null;
5151
$key = $parameter->getKey();
@@ -72,7 +72,6 @@ private function extractParameterValues(Parameter $parameter, array $values): st
7272
$value = $value[$accessor];
7373
} else {
7474
$value = new ParameterNotFound();
75-
continue;
7675
}
7776
}
7877

@@ -100,6 +99,14 @@ private function extractParameterValues(Parameter $parameter, array $values): st
10099
$value = $value[0];
101100
}
102101

102+
if (true === $parameter->getCastToNativeType() && ($castFn = $parameter->getCastFn())) {
103+
if (\is_array($value)) {
104+
$value = array_map(fn ($v) => $castFn($v, $parameter), $value);
105+
} else {
106+
$value = $castFn($value, $parameter);
107+
}
108+
}
109+
103110
return $value;
104111
}
105112
}

src/Validator/Util/ParameterValidationConstraints.php

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -144,8 +144,16 @@ public static function getParameterValidationConstraints(Parameter $parameter, ?
144144
$assertions[] = new Unique();
145145
}
146146

147-
if (isset($schema['type']) && 'array' === $schema['type']) {
148-
$assertions[] = new Type(type: 'array');
147+
if (isset($schema['type'])) {
148+
$assertion = match ($schema['type']) {
149+
'array', 'boolean', 'integer' => new Type(type: $schema['type']),
150+
'number' => new Type(type: 'float'),
151+
default => null,
152+
};
153+
154+
if ($assertion) {
155+
$assertions[] = $assertion;
156+
}
149157
}
150158

151159
return $assertions;

tests/Fixtures/TestBundle/ApiResource/WithParameter.php

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,77 @@
171171
'minimum' => 1,
172172
'maximum' => 5,
173173
],
174-
required: true,
174+
),
175+
],
176+
provider: [self::class, 'noopProvider']
177+
)]
178+
#[GetCollection(
179+
uriTemplate: 'header_float',
180+
parameters: [
181+
'Bar' => new HeaderParameter(
182+
schema: [
183+
'type' => 'number',
184+
'example' => 42.0,
185+
'minimum' => 1.0,
186+
'maximum' => 100.0,
187+
'multipleOf' => 0.01,
188+
],
189+
castToNativeType: true
190+
),
191+
],
192+
provider: [self::class, 'noopProvider']
193+
)]
194+
#[GetCollection(
195+
uriTemplate: 'header_boolean',
196+
parameters: [
197+
'Lorem' => new HeaderParameter(
198+
schema: [
199+
'type' => 'boolean',
200+
],
201+
castToNativeType: true,
202+
),
203+
],
204+
provider: [self::class, 'noopProvider']
205+
)]
206+
#[GetCollection(
207+
uriTemplate: 'query_integer',
208+
parameters: [
209+
'Foo' => new QueryParameter(
210+
schema: [
211+
'type' => 'integer',
212+
'example' => 3,
213+
'minimum' => 1,
214+
'maximum' => 5,
215+
],
216+
castToNativeType: true
217+
),
218+
],
219+
provider: [self::class, 'noopProvider']
220+
)]
221+
#[GetCollection(
222+
uriTemplate: 'query_float',
223+
parameters: [
224+
'Bar' => new QueryParameter(
225+
schema: [
226+
'type' => 'number',
227+
'example' => 42.0,
228+
'minimum' => 1.0,
229+
'maximum' => 100.0,
230+
'multipleOf' => 0.01,
231+
],
232+
castToNativeType: true
233+
),
234+
],
235+
provider: [self::class, 'noopProvider']
236+
)]
237+
#[GetCollection(
238+
uriTemplate: 'query_boolean',
239+
parameters: [
240+
'Lorem' => new QueryParameter(
241+
schema: [
242+
'type' => 'boolean',
243+
],
244+
castToNativeType: true,
175245
),
176246
],
177247
provider: [self::class, 'noopProvider']

tests/Functional/Parameters/BooleanFilterTest.php

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -98,14 +98,8 @@ public static function booleanFilterScenariosProvider(): \Generator
9898
#[DataProvider('booleanFilterNullAndEmptyScenariosProvider')]
9999
public function testBooleanFilterWithNullAndEmptyValues(string $url): void
100100
{
101-
$response = self::createClient()->request('GET', $url);
102-
$this->assertResponseIsSuccessful();
103-
104-
$responseData = $response->toArray();
105-
$filteredItems = $responseData['hydra:member'];
106-
107-
$expectedItemCount = 3;
108-
$this->assertCount($expectedItemCount, $filteredItems, \sprintf('Expected %d items for URL %s', $expectedItemCount, $url));
101+
self::createClient()->request('GET', $url);
102+
$this->assertResponseStatusCodeSame(422);
109103
}
110104

111105
public static function booleanFilterNullAndEmptyScenariosProvider(): \Generator

0 commit comments

Comments
 (0)