Skip to content

Commit 8885000

Browse files
feat(state): cast parameter values to validate with the Type constraint (#7240)
1 parent 7896d65 commit 8885000

File tree

9 files changed

+269
-16
lines changed

9 files changed

+269
-16
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: 36 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(mixed): mixed $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 mixed $castFn = null,
5458
) {
5559
}
5660

@@ -332,4 +336,33 @@ 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+
/**
359+
* @param callable(mixed): mixed $castFn
360+
*/
361+
public function withCastFn(mixed $castFn): self
362+
{
363+
$self = clone $this;
364+
$self->castFn = $castFn;
365+
366+
return $self;
367+
}
335368
}

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
}

src/State/Parameter/ValueCaster.php

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 & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,8 @@ private function getParameterValues(Parameter $parameter, ?Request $request, arr
4242

4343
/**
4444
* @param array<string, mixed> $values
45-
*
46-
* @return array<mixed, mixed>|ParameterNotFound|array
4745
*/
48-
private function extractParameterValues(Parameter $parameter, array $values): string|ParameterNotFound|array
46+
private function extractParameterValues(Parameter $parameter, array $values): mixed
4947
{
5048
$accessors = null;
5149
$key = $parameter->getKey();
@@ -72,7 +70,6 @@ private function extractParameterValues(Parameter $parameter, array $values): st
7270
$value = $value[$accessor];
7371
} else {
7472
$value = new ParameterNotFound();
75-
continue;
7673
}
7774
}
7875

@@ -100,6 +97,14 @@ private function extractParameterValues(Parameter $parameter, array $values): st
10097
$value = $value[0];
10198
}
10299

100+
if (true === $parameter->getCastToNativeType() && ($castFn = $parameter->getCastFn())) {
101+
if (\is_array($value)) {
102+
$value = array_map(fn ($v) => $castFn($v, $parameter), $value);
103+
} else {
104+
$value = $castFn($value, $parameter);
105+
}
106+
}
107+
103108
return $value;
104109
}
105110
}

src/State/composer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
],
2929
"require": {
3030
"php": ">=8.2",
31-
"api-platform/metadata": "^4.1.11",
31+
"api-platform/metadata": "^4.1.18",
3232
"psr/container": "^1.0 || ^2.0",
3333
"symfony/http-kernel": "^6.4 || ^7.0",
3434
"symfony/serializer": "^6.4 || ^7.0",

src/Validator/Util/ParameterValidationConstraints.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,18 @@ public static function getParameterValidationConstraints(Parameter $parameter, ?
148148
$assertions[] = new Type(type: 'array');
149149
}
150150

151+
if (isset($schema['type']) && $parameter->getCastToNativeType()) {
152+
$assertion = match ($schema['type']) {
153+
'boolean', 'integer' => new Type(type: $schema['type']),
154+
'number' => new Type(type: 'float'),
155+
default => null,
156+
};
157+
158+
if ($assertion) {
159+
$assertions[] = $assertion;
160+
}
161+
}
162+
151163
return $assertions;
152164
}
153165
}

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/ParameterTest.php

Lines changed: 60 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -121,18 +121,72 @@ public function testHeaderParameterRequired(): void
121121
}
122122

123123
#[DataProvider('provideHeaderValues')]
124-
public function testHeaderParameterInteger(string $value, int $expectedStatusCode): void
124+
public function testHeaderParameter(string $url, array $headers, int $expectedStatusCode): void
125125
{
126-
self::createClient()->request('GET', 'header_integer', ['headers' => ['Foo' => $value]]);
126+
self::createClient()->request('GET', $url, ['headers' => $headers]);
127127
$this->assertResponseStatusCodeSame($expectedStatusCode);
128128
}
129129

130130
public static function provideHeaderValues(): iterable
131131
{
132-
yield 'valid integer' => ['3', 200];
133-
yield 'too high' => ['6', 422];
134-
yield 'too low' => ['0', 422];
135-
yield 'invalid integer' => ['string', 422];
132+
// header_integer
133+
yield 'missing header header_integer' => ['header_integer', [], 200];
134+
yield 'valid integer header_integer' => ['header_integer', ['Foo' => '3'], 200];
135+
yield 'too high header_integer' => ['header_integer', ['Foo' => '6'], 422];
136+
yield 'too low header_integer' => ['header_integer', ['Foo' => '0'], 422];
137+
yield 'invalid integer header_integer' => ['header_integer', ['Foo' => 'string'], 422];
138+
139+
// header_float
140+
yield 'missing header header_float' => ['header_float', [], 200];
141+
yield 'valid float header_float' => ['header_float', ['Bar' => '3.5'], 200];
142+
yield 'valid integer header_float' => ['header_float', ['Bar' => '3'], 200];
143+
yield 'too high header_float' => ['header_float', ['Bar' => '600'], 422];
144+
yield 'too low header_float' => ['header_float', ['Bar' => '0'], 422];
145+
yield 'invalid number header_float' => ['header_float', ['Bar' => 'string'], 422];
146+
147+
// header_boolean
148+
yield 'missing header header_boolean' => ['header_boolean', [], 200];
149+
yield 'valid boolean false header_boolean' => ['header_boolean', ['Lorem' => 'false'], 200];
150+
yield 'valid boolean true header_boolean' => ['header_boolean', ['Lorem' => 'true'], 200];
151+
yield 'valid boolean 0 header_boolean' => ['header_boolean', ['Lorem' => 0], 200];
152+
yield 'valid boolean 0 string header_boolean' => ['header_boolean', ['Lorem' => '0'], 200];
153+
yield 'valid boolean 1 header_boolean' => ['header_boolean', ['Lorem' => 1], 200];
154+
yield 'valid boolean 1 string header_boolean' => ['header_boolean', ['Lorem' => '1'], 200];
155+
yield 'invalid boolean header_boolean' => ['header_boolean', ['Lorem' => 'string'], 422];
156+
}
157+
158+
#[DataProvider('provideQueryValues')]
159+
public function testQueryParameter(string $url, array $query, int $expectedStatusCode): void
160+
{
161+
self::createClient()->request('GET', $url, ['query' => $query]);
162+
$this->assertResponseStatusCodeSame($expectedStatusCode);
163+
}
164+
165+
public static function provideQueryValues(): iterable
166+
{
167+
// query_integer
168+
yield 'valid integer query_integer' => ['query_integer', ['Foo' => '3'], 200];
169+
yield 'too high query_integer' => ['query_integer', ['Foo' => '6'], 422];
170+
yield 'too low query_integer' => ['query_integer', ['Foo' => '0'], 422];
171+
yield 'invalid integer query_integer' => ['query_integer', ['Foo' => 'string'], 422];
172+
173+
// query_float
174+
yield 'valid float query_float' => ['query_float', ['Bar' => '3.5'], 200];
175+
yield 'valid integer query_float' => ['query_float', ['Bar' => '3'], 200];
176+
yield 'too high query_float' => ['query_float', ['Bar' => '600'], 422];
177+
yield 'too low query_float' => ['query_float', ['Bar' => '0'], 422];
178+
yield 'invalid number query_float' => ['query_float', ['Bar' => 'string'], 422];
179+
180+
// query_boolean
181+
yield 'valid boolean false query_boolean' => ['query_boolean', ['Lorem' => false], 200];
182+
yield 'valid boolean false string query_boolean' => ['query_boolean', ['Lorem' => 'false'], 200];
183+
yield 'valid boolean true query_boolean' => ['query_boolean', ['Lorem' => true], 200];
184+
yield 'valid boolean true string query_boolean' => ['query_boolean', ['Lorem' => 'true'], 200];
185+
yield 'valid boolean 0 query_boolean' => ['query_boolean', ['Lorem' => 0], 200];
186+
yield 'valid boolean 0 string query_boolean' => ['query_boolean', ['Lorem' => '0'], 200];
187+
yield 'valid boolean 1 query_boolean' => ['query_boolean', ['Lorem' => 1], 200];
188+
yield 'valid boolean 1 string query_boolean' => ['query_boolean', ['Lorem' => '1'], 200];
189+
yield 'invalid boolean query_boolean' => ['query_boolean', ['Lorem' => 'string'], 422];
136190
}
137191

138192
#[DataProvider('provideCountryValues')]

0 commit comments

Comments
 (0)