Skip to content

Commit bf271d1

Browse files
authored
fix(validator): parameter validation list<string>|string (#7245)
1 parent d3e73f0 commit bf271d1

File tree

4 files changed

+176
-112
lines changed

4 files changed

+176
-112
lines changed

src/Validator/Metadata/Resource/Factory/ParameterValidationResourceMetadataCollectionFactory.php

Lines changed: 7 additions & 111 deletions
Original file line numberDiff line numberDiff line change
@@ -20,28 +20,16 @@
2020
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
2121
use ApiPlatform\Metadata\Resource\ResourceMetadataCollection;
2222
use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter;
23+
use ApiPlatform\Validator\Util\ParameterValidationConstraints;
2324
use Psr\Container\ContainerInterface;
24-
use Symfony\Component\TypeInfo\Type\CollectionType;
25-
use Symfony\Component\TypeInfo\Type\UnionType;
26-
use Symfony\Component\Validator\Constraints\All;
27-
use Symfony\Component\Validator\Constraints\Choice;
28-
use Symfony\Component\Validator\Constraints\Collection;
29-
use Symfony\Component\Validator\Constraints\Count;
30-
use Symfony\Component\Validator\Constraints\DivisibleBy;
31-
use Symfony\Component\Validator\Constraints\GreaterThan;
32-
use Symfony\Component\Validator\Constraints\GreaterThanOrEqual;
33-
use Symfony\Component\Validator\Constraints\Length;
34-
use Symfony\Component\Validator\Constraints\LessThan;
35-
use Symfony\Component\Validator\Constraints\LessThanOrEqual;
36-
use Symfony\Component\Validator\Constraints\NotBlank;
37-
use Symfony\Component\Validator\Constraints\NotNull;
38-
use Symfony\Component\Validator\Constraints\Range;
39-
use Symfony\Component\Validator\Constraints\Regex;
40-
use Symfony\Component\Validator\Constraints\Type;
41-
use Symfony\Component\Validator\Constraints\Unique;
4225

26+
/**
27+
* @experimental
28+
*/
4329
final class ParameterValidationResourceMetadataCollectionFactory implements ResourceMetadataCollectionFactoryInterface
4430
{
31+
use ParameterValidationConstraints;
32+
4533
public function __construct(
4634
private readonly ?ResourceMetadataCollectionFactoryInterface $decorated = null,
4735
private readonly ?ContainerInterface $filterLocator = null,
@@ -100,99 +88,7 @@ private function addSchemaValidation(Parameter $parameter, ?array $schema = null
10088
return $parameter;
10189
}
10290

103-
$schema ??= $parameter->getSchema();
104-
$required ??= $parameter->getRequired() ?? false;
105-
$openApi ??= $parameter->getOpenApi();
106-
107-
// When it's an array of openapi parameters take the first one as it's probably just a variant of the query parameter,
108-
// only getAllowEmptyValue is used here anyways
109-
if (\is_array($openApi)) {
110-
$openApi = $openApi[0];
111-
} elseif (false === $openApi) {
112-
$openApi = null;
113-
}
114-
115-
$assertions = [];
116-
$allowEmptyValue = $openApi?->getAllowEmptyValue();
117-
if (false === ($allowEmptyValue ?? $openApi?->getAllowEmptyValue())) {
118-
$assertions[] = new NotBlank(allowNull: !$required);
119-
}
120-
121-
$minimum = $schema['exclusiveMinimum'] ?? $schema['minimum'] ?? null;
122-
$exclusiveMinimum = isset($schema['exclusiveMinimum']);
123-
$maximum = $schema['exclusiveMaximum'] ?? $schema['maximum'] ?? null;
124-
$exclusiveMaximum = isset($schema['exclusiveMaximum']);
125-
126-
if ($minimum && $maximum) {
127-
if (!$exclusiveMinimum && !$exclusiveMaximum) {
128-
$assertions[] = new Range(min: $minimum, max: $maximum);
129-
} else {
130-
$assertions[] = $exclusiveMinimum ? new GreaterThan(value: $minimum) : new GreaterThanOrEqual(value: $minimum);
131-
$assertions[] = $exclusiveMaximum ? new LessThan(value: $maximum) : new LessThanOrEqual(value: $maximum);
132-
}
133-
} elseif ($minimum) {
134-
$assertions[] = $exclusiveMinimum ? new GreaterThan(value: $minimum) : new GreaterThanOrEqual(value: $minimum);
135-
} elseif ($maximum) {
136-
$assertions[] = $exclusiveMaximum ? new LessThan(value: $maximum) : new LessThanOrEqual(value: $maximum);
137-
}
138-
139-
if (isset($schema['pattern'])) {
140-
$assertions[] = new Regex('#'.$schema['pattern'].'#');
141-
}
142-
143-
if (isset($schema['maxLength']) || isset($schema['minLength'])) {
144-
$assertions[] = new Length(min: $schema['minLength'] ?? null, max: $schema['maxLength'] ?? null);
145-
}
146-
147-
if (isset($schema['multipleOf'])) {
148-
$assertions[] = new DivisibleBy(value: $schema['multipleOf']);
149-
}
150-
151-
if (isset($schema['enum'])) {
152-
$assertions[] = new Choice(choices: $schema['enum']);
153-
}
154-
155-
if ($properties = $parameter->getExtraProperties()['_properties'] ?? []) {
156-
$fields = [];
157-
foreach ($properties as $propertyName) {
158-
$fields[$propertyName] = $assertions;
159-
}
160-
161-
return $parameter->withConstraints(new Collection(fields: $fields, allowMissingFields: true));
162-
}
163-
164-
$isCollectionType = fn ($t) => $t instanceof CollectionType;
165-
$isCollection = $parameter->getNativeType()?->isSatisfiedBy($isCollectionType) ?? false;
166-
167-
// type-info 7.2
168-
if (!$isCollection && $parameter->getNativeType() instanceof UnionType) {
169-
foreach ($parameter->getNativeType()->getTypes() as $t) {
170-
if ($isCollection = $t->isSatisfiedBy($isCollectionType)) {
171-
break;
172-
}
173-
}
174-
}
175-
176-
if ($isCollection) {
177-
$assertions = $assertions ? [new All($assertions)] : [];
178-
}
179-
180-
if ($required && false !== $allowEmptyValue) {
181-
$assertions[] = new NotNull(message: \sprintf('The parameter "%s" is required.', $parameter->getKey()));
182-
}
183-
184-
if (isset($schema['minItems']) || isset($schema['maxItems'])) {
185-
$assertions[] = new Count(min: $schema['minItems'] ?? null, max: $schema['maxItems'] ?? null);
186-
}
187-
188-
if ($schema['uniqueItems'] ?? false) {
189-
$assertions[] = new Unique();
190-
}
191-
192-
if (isset($schema['type']) && 'array' === $schema['type']) {
193-
$assertions[] = new Type(type: 'array');
194-
}
195-
91+
$assertions = $this->getParameterValidationConstraints($parameter, $schema, $required, $openApi);
19692
if (!$assertions) {
19793
return $parameter;
19894
}
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
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\Validator\Util;
15+
16+
use ApiPlatform\Metadata\Parameter;
17+
use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter;
18+
use Symfony\Component\TypeInfo\Type\CollectionType;
19+
use Symfony\Component\TypeInfo\Type\UnionType;
20+
use Symfony\Component\Validator\Constraint;
21+
use Symfony\Component\Validator\Constraints\All;
22+
use Symfony\Component\Validator\Constraints\AtLeastOneOf;
23+
use Symfony\Component\Validator\Constraints\Choice;
24+
use Symfony\Component\Validator\Constraints\Collection;
25+
use Symfony\Component\Validator\Constraints\Count;
26+
use Symfony\Component\Validator\Constraints\DivisibleBy;
27+
use Symfony\Component\Validator\Constraints\GreaterThan;
28+
use Symfony\Component\Validator\Constraints\GreaterThanOrEqual;
29+
use Symfony\Component\Validator\Constraints\Length;
30+
use Symfony\Component\Validator\Constraints\LessThan;
31+
use Symfony\Component\Validator\Constraints\LessThanOrEqual;
32+
use Symfony\Component\Validator\Constraints\NotBlank;
33+
use Symfony\Component\Validator\Constraints\NotNull;
34+
use Symfony\Component\Validator\Constraints\Range;
35+
use Symfony\Component\Validator\Constraints\Regex;
36+
use Symfony\Component\Validator\Constraints\Sequentially;
37+
use Symfony\Component\Validator\Constraints\Type;
38+
use Symfony\Component\Validator\Constraints\Unique;
39+
40+
/**
41+
* Helper to get a set of validation constraints for a given Parameter.
42+
*
43+
* @experimental
44+
*/
45+
trait ParameterValidationConstraints
46+
{
47+
/**
48+
* @param Parameter $parameter readonly
49+
*
50+
* @return list<Constraint>
51+
*/
52+
public static function getParameterValidationConstraints(Parameter $parameter, ?array $schema = null, ?bool $required = null, ?OpenApiParameter $openApi = null): array
53+
{
54+
$schema ??= $parameter->getSchema();
55+
$required ??= $parameter->getRequired() ?? false;
56+
$openApi ??= $parameter->getOpenApi();
57+
58+
// When it's an array of openapi parameters take the first one as it's probably just a variant of the query parameter,
59+
// only getAllowEmptyValue is used here anyways
60+
if (\is_array($openApi)) {
61+
$openApi = $openApi[0];
62+
} elseif (false === $openApi) {
63+
$openApi = null;
64+
}
65+
66+
$assertions = [];
67+
$allowEmptyValue = $openApi?->getAllowEmptyValue();
68+
if (false === ($allowEmptyValue ?? $openApi?->getAllowEmptyValue())) {
69+
$assertions[] = new NotBlank(allowNull: !$required);
70+
}
71+
72+
$minimum = $schema['exclusiveMinimum'] ?? $schema['minimum'] ?? null;
73+
$exclusiveMinimum = isset($schema['exclusiveMinimum']);
74+
$maximum = $schema['exclusiveMaximum'] ?? $schema['maximum'] ?? null;
75+
$exclusiveMaximum = isset($schema['exclusiveMaximum']);
76+
77+
if ($minimum && $maximum) {
78+
if (!$exclusiveMinimum && !$exclusiveMaximum) {
79+
$assertions[] = new Range(min: $minimum, max: $maximum);
80+
} else {
81+
$assertions[] = $exclusiveMinimum ? new GreaterThan(value: $minimum) : new GreaterThanOrEqual(value: $minimum);
82+
$assertions[] = $exclusiveMaximum ? new LessThan(value: $maximum) : new LessThanOrEqual(value: $maximum);
83+
}
84+
} elseif ($minimum) {
85+
$assertions[] = $exclusiveMinimum ? new GreaterThan(value: $minimum) : new GreaterThanOrEqual(value: $minimum);
86+
} elseif ($maximum) {
87+
$assertions[] = $exclusiveMaximum ? new LessThan(value: $maximum) : new LessThanOrEqual(value: $maximum);
88+
}
89+
90+
if (isset($schema['pattern'])) {
91+
$assertions[] = new Regex('#'.$schema['pattern'].'#');
92+
}
93+
94+
if (isset($schema['maxLength']) || isset($schema['minLength'])) {
95+
$assertions[] = new Length(min: $schema['minLength'] ?? null, max: $schema['maxLength'] ?? null);
96+
}
97+
98+
if (isset($schema['multipleOf'])) {
99+
$assertions[] = new DivisibleBy(value: $schema['multipleOf']);
100+
}
101+
102+
if (isset($schema['enum'])) {
103+
$assertions[] = new Choice(choices: $schema['enum']);
104+
}
105+
106+
if ($properties = $parameter->getExtraProperties()['_properties'] ?? []) {
107+
$fields = [];
108+
foreach ($properties as $propertyName) {
109+
$fields[$propertyName] = $assertions;
110+
}
111+
112+
return [new Collection(fields: $fields, allowMissingFields: true)];
113+
}
114+
115+
$isCollectionType = fn ($t) => $t instanceof CollectionType;
116+
$isCollection = $parameter->getNativeType()?->isSatisfiedBy($isCollectionType) ?? false;
117+
118+
// type-info 7.2
119+
if (!$isCollection && $parameter->getNativeType() instanceof UnionType) {
120+
foreach ($parameter->getNativeType()->getTypes() as $t) {
121+
if ($isCollection = $t->isSatisfiedBy($isCollectionType)) {
122+
break;
123+
}
124+
}
125+
}
126+
127+
if ($isCollection) {
128+
if (true === ($parameter->getCastToArray() ?? false)) {
129+
$assertions = $assertions ? [new All($assertions)] : [];
130+
} else {
131+
$assertions = $assertions ? [new AtLeastOneOf([new Sequentially($assertions), new All($assertions)])] : [];
132+
}
133+
}
134+
135+
if ($required && false !== $allowEmptyValue) {
136+
$assertions[] = new NotNull(message: \sprintf('The parameter "%s" is required.', $parameter->getKey()));
137+
}
138+
139+
if (isset($schema['minItems']) || isset($schema['maxItems'])) {
140+
$assertions[] = new Count(min: $schema['minItems'] ?? null, max: $schema['maxItems'] ?? null);
141+
}
142+
143+
if ($schema['uniqueItems'] ?? false) {
144+
$assertions[] = new Unique();
145+
}
146+
147+
if (isset($schema['type']) && 'array' === $schema['type']) {
148+
$assertions[] = new Type(type: 'array');
149+
}
150+
151+
return $assertions;
152+
}
153+
}

tests/Fixtures/TestBundle/ApiResource/WithParameter.php

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,11 +95,17 @@
9595
#[GetCollection(
9696
uriTemplate: 'validate_parameters{._format}',
9797
parameters: [
98-
'enum' => new QueryParameter(schema: ['enum' => ['a', 'b'], 'uniqueItems' => true]),
98+
'enum' => new QueryParameter(
99+
schema: ['enum' => ['a', 'b'], 'uniqueItems' => true],
100+
castToArray: true
101+
),
99102
'num' => new QueryParameter(
100103
schema: ['minimum' => 1, 'maximum' => 3],
101104
nativeType: new BuiltinType(TypeIdentifier::STRING),
102105
),
106+
'numMultipleType' => new QueryParameter(
107+
schema: ['minimum' => 1, 'maximum' => 3],
108+
),
103109
'exclusiveNum' => new QueryParameter(
104110
schema: ['exclusiveMinimum' => 1, 'exclusiveMaximum' => 3],
105111
nativeType: new BuiltinType(TypeIdentifier::STRING),

tests/Functional/Parameters/ValidationTest.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,15 @@ public static function provideQueryStrings(): array
107107
['propertyPath' => 'num', 'message' => 'This value should be between 1 and 3.'],
108108
],
109109
],
110+
[
111+
'numMultipleType=5',
112+
[
113+
[
114+
'propertyPath' => 'numMultipleType',
115+
'message' => 'This value should satisfy at least one of the following constraints: [1] This value should be between 1 and 3. [2] Each element of this collection should satisfy its own set of constraints.',
116+
],
117+
],
118+
],
110119
[
111120
'exclusiveNum=5',
112121
[

0 commit comments

Comments
 (0)