Skip to content

Commit d5a2cb0

Browse files
aqvadora.chelnokov
andauthored
feat: add validation for array items via ChainValidator delegation (#920)
Co-authored-by: a.chelnokov <a.chelnokov@rko-group.ru>
1 parent b78adcb commit d5a2cb0

File tree

7 files changed

+317
-15
lines changed

7 files changed

+317
-15
lines changed

Generator/ValidatorGenerator.php

Lines changed: 27 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -128,20 +128,7 @@ private function generateConstraint(ValidatorGuess $guess): Expr
128128
{
129129
$args = [];
130130
foreach ($guess->getArguments() as $argName => $argument) {
131-
$value = null;
132-
if (\is_array($argument)) {
133-
$values = [];
134-
foreach ($argument as $item) {
135-
$values[] = new Expr\ArrayItem(new Scalar\String_($item));
136-
}
137-
$value = new Expr\Array_($values);
138-
} elseif (\is_string($argument)) {
139-
$value = new Scalar\String_($argument);
140-
} elseif (\is_int($argument)) {
141-
$value = new Scalar\LNumber($argument);
142-
} elseif (\is_float($argument)) {
143-
$value = new Scalar\DNumber($argument);
144-
}
131+
$value = $this->generateConstraintArgument($argument);
145132

146133
if (null !== $value) {
147134
$args[] = new Node\Arg($value, name: new Node\Identifier($argName));
@@ -150,4 +137,30 @@ private function generateConstraint(ValidatorGuess $guess): Expr
150137

151138
return new Expr\New_(new Node\Name\FullyQualified($guess->getConstraintClass()), $args);
152139
}
140+
141+
private function generateConstraintArgument($argument): ?Expr
142+
{
143+
if ($argument instanceof ValidatorGuess) {
144+
return $this->generateConstraint($argument);
145+
}
146+
if (\is_array($argument)) {
147+
$values = [];
148+
foreach ($argument as $item) {
149+
$values[] = new Expr\ArrayItem($this->generateConstraintArgument($item));
150+
}
151+
152+
return new Expr\Array_($values);
153+
}
154+
if (\is_string($argument)) {
155+
return new Scalar\String_($argument);
156+
}
157+
if (\is_int($argument)) {
158+
return new Scalar\LNumber($argument);
159+
}
160+
if (\is_float($argument)) {
161+
return new Scalar\DNumber($argument);
162+
}
163+
164+
return null;
165+
}
153166
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
<?php
2+
3+
namespace Jane\Component\JsonSchema\Guesser\Validator\Array_;
4+
5+
use Jane\Component\JsonSchema\Guesser\Guess\ClassGuess;
6+
use Jane\Component\JsonSchema\Guesser\Guess\Property;
7+
use Jane\Component\JsonSchema\Guesser\Validator\ObjectCheckTrait;
8+
use Jane\Component\JsonSchema\Guesser\Validator\ValidatorGuess;
9+
use Jane\Component\JsonSchema\Guesser\Validator\ValidatorGuessCollector;
10+
use Jane\Component\JsonSchema\Guesser\Validator\ValidatorInterface;
11+
use Symfony\Component\Validator\Constraints\All;
12+
use Symfony\Component\Validator\Constraints\NotNull;
13+
14+
class ItemsValidator implements ValidatorInterface
15+
{
16+
use ObjectCheckTrait;
17+
18+
public function __construct(
19+
private readonly ValidatorInterface $chainValidator,
20+
) {
21+
}
22+
23+
public function supports($object): bool
24+
{
25+
if (!$this->checkObject($object)) {
26+
return false;
27+
}
28+
29+
$type = $object->getType();
30+
$isArray = \is_array($type) ? \in_array('array', $type) : 'array' === $type;
31+
32+
return $isArray && null !== $object->getItems() && $this->checkObject($object->getItems());
33+
}
34+
35+
/**
36+
* @param ClassGuess|Property $guess
37+
*/
38+
public function guess($object, string $name, $guess): void
39+
{
40+
$collector = new ValidatorGuessCollector();
41+
42+
$this->chainValidator->guess($object->getItems(), $name, $collector);
43+
44+
$constraints = array_filter(
45+
$collector->getValidatorGuesses(),
46+
static fn (ValidatorGuess $g) => NotNull::class !== $g->getConstraintClass(),
47+
);
48+
49+
if (\count($constraints) > 0) {
50+
$guess->addValidatorGuess(new ValidatorGuess(All::class, [
51+
'constraints' => array_values($constraints),
52+
]));
53+
}
54+
}
55+
}

Guesser/Validator/ChainValidatorFactory.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ public static function create(Naming $naming, Registry $registry, DenormalizerIn
2525
$chainValidator->addValidator(new Array_\MaxItemsValidator());
2626
$chainValidator->addValidator(new Array_\MinItemsValidator());
2727
$chainValidator->addValidator(new Array_\UniqueItemsValidator());
28+
$chainValidator->addValidator(new Array_\ItemsValidator($chainValidator));
2829
// Object
2930
$chainValidator->addValidator(new Object_\SubObjectValidator($denormalizer, $naming, $registry));
3031
$chainValidator->addValidator(new Object_\MaxPropertiesValidator());
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?php
2+
3+
namespace Jane\Component\JsonSchema\Guesser\Validator;
4+
5+
use Jane\Component\JsonSchema\Guesser\Guess\ValidatorGuessTrait;
6+
7+
class ValidatorGuessCollector
8+
{
9+
use ValidatorGuessTrait;
10+
11+
public function getReference(): string
12+
{
13+
return 'properties';
14+
}
15+
}

Tests/Validation/ValidationTest.php

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
use Jane\Component\JsonSchema\Console\Command\GenerateCommand;
66
use Jane\Component\JsonSchema\Console\Loader\ConfigLoader;
77
use Jane\Component\JsonSchema\Console\Loader\SchemaLoader;
8+
use Jane\Component\JsonSchema\Tests\Validation\Generated\Model\ArrayItemsObject;
89
use Jane\Component\JsonSchema\Tests\Validation\Generated\Model\ArrayObject;
910
use Jane\Component\JsonSchema\Tests\Validation\Generated\Model\FormatObject;
1011
use Jane\Component\JsonSchema\Tests\Validation\Generated\Model\NumericObject;
@@ -16,6 +17,7 @@
1617
use Jane\Component\JsonSchema\Tests\Validation\Generated\Model\StringObject;
1718
use Jane\Component\JsonSchema\Tests\Validation\Generated\Model\TypeObject;
1819
use Jane\Component\JsonSchema\Tests\Validation\Generated\Model\VerifyNullableStringPropertyWithMinLengthValidatesCorrectly;
20+
use Jane\Component\JsonSchema\Tests\Validation\Generated\Normalizer\ArrayItemsObjectNormalizer;
1921
use Jane\Component\JsonSchema\Tests\Validation\Generated\Normalizer\ArrayObjectNormalizer;
2022
use Jane\Component\JsonSchema\Tests\Validation\Generated\Normalizer\FormatObjectNormalizer;
2123
use Jane\Component\JsonSchema\Tests\Validation\Generated\Normalizer\NumericObjectNormalizer;
@@ -81,6 +83,9 @@ public function testValidation(): void
8183

8284
// 12.
8385
$this->verifyNullableStringPropertyWithMinLengthValidatesCorrectly();
86+
87+
// 13. Array items validation
88+
$this->arrayItemsValidation();
8489
}
8590

8691
private function numericValidation(): void
@@ -948,4 +953,168 @@ private function verifyNullableStringPropertyWithMinLengthValidatesCorrectly():
948953
self::assertInstanceOf(VerifyNullableStringPropertyWithMinLengthValidatesCorrectly::class, $data);
949954
self::assertNull($data->getName());
950955
}
956+
957+
private function arrayItemsValidation(): void
958+
{
959+
$normalizer = new ArrayItemsObjectNormalizer();
960+
961+
// Valid uuid array
962+
$data = $normalizer->denormalize([
963+
'uuidArray' => ['8309e3b3-0c6c-4ab8-b450-e7564f6d07fd', 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'],
964+
], ArrayItemsObject::class);
965+
$this->assertInstanceOf(ArrayItemsObject::class, $data);
966+
967+
// Invalid uuid in array
968+
$caughtException = null;
969+
try {
970+
$normalizer->denormalize([
971+
'uuidArray' => ['8309e3b3-0c6c-4ab8-b450-e7564f6d07fd', 'not-a-uuid'],
972+
], ArrayItemsObject::class);
973+
} catch (ValidationException $exception) {
974+
$caughtException = $exception;
975+
}
976+
977+
$this->assertInstanceOf(ValidationException::class, $caughtException);
978+
$this->assertEquals(400, $caughtException->getCode());
979+
$this->assertGreaterThanOrEqual(1, $caughtException->getViolationList()->count());
980+
981+
// Valid enum string array
982+
$data = $normalizer->denormalize([
983+
'enumStringArray' => ['alpha', 'beta'],
984+
], ArrayItemsObject::class);
985+
$this->assertInstanceOf(ArrayItemsObject::class, $data);
986+
987+
// Invalid enum value in array
988+
$caughtException = null;
989+
try {
990+
$normalizer->denormalize([
991+
'enumStringArray' => ['alpha', 'invalid'],
992+
], ArrayItemsObject::class);
993+
} catch (ValidationException $exception) {
994+
$caughtException = $exception;
995+
}
996+
997+
$this->assertInstanceOf(ValidationException::class, $caughtException);
998+
$this->assertEquals(400, $caughtException->getCode());
999+
$this->assertGreaterThanOrEqual(1, $caughtException->getViolationList()->count());
1000+
1001+
// Valid constrained string array
1002+
$data = $normalizer->denormalize([
1003+
'constrainedStringArray' => ['ab', 'abcde'],
1004+
], ArrayItemsObject::class);
1005+
$this->assertInstanceOf(ArrayItemsObject::class, $data);
1006+
1007+
// String too short in array
1008+
$caughtException = null;
1009+
try {
1010+
$normalizer->denormalize([
1011+
'constrainedStringArray' => ['a'],
1012+
], ArrayItemsObject::class);
1013+
} catch (ValidationException $exception) {
1014+
$caughtException = $exception;
1015+
}
1016+
1017+
$this->assertInstanceOf(ValidationException::class, $caughtException);
1018+
$this->assertEquals(400, $caughtException->getCode());
1019+
$this->assertGreaterThanOrEqual(1, $caughtException->getViolationList()->count());
1020+
1021+
// String too long in array
1022+
$caughtException = null;
1023+
try {
1024+
$normalizer->denormalize([
1025+
'constrainedStringArray' => ['this-is-way-too-long-string'],
1026+
], ArrayItemsObject::class);
1027+
} catch (ValidationException $exception) {
1028+
$caughtException = $exception;
1029+
}
1030+
1031+
$this->assertInstanceOf(ValidationException::class, $caughtException);
1032+
$this->assertEquals(400, $caughtException->getCode());
1033+
$this->assertGreaterThanOrEqual(1, $caughtException->getViolationList()->count());
1034+
1035+
// Valid constrained integer array
1036+
$data = $normalizer->denormalize([
1037+
'constrainedIntegerArray' => [1, 50, 100],
1038+
], ArrayItemsObject::class);
1039+
$this->assertInstanceOf(ArrayItemsObject::class, $data);
1040+
1041+
// Integer below minimum in array
1042+
$caughtException = null;
1043+
try {
1044+
$normalizer->denormalize([
1045+
'constrainedIntegerArray' => [0],
1046+
], ArrayItemsObject::class);
1047+
} catch (ValidationException $exception) {
1048+
$caughtException = $exception;
1049+
}
1050+
1051+
$this->assertInstanceOf(ValidationException::class, $caughtException);
1052+
$this->assertEquals(400, $caughtException->getCode());
1053+
$this->assertGreaterThanOrEqual(1, $caughtException->getViolationList()->count());
1054+
1055+
// Integer above maximum in array
1056+
$caughtException = null;
1057+
try {
1058+
$normalizer->denormalize([
1059+
'constrainedIntegerArray' => [101],
1060+
], ArrayItemsObject::class);
1061+
} catch (ValidationException $exception) {
1062+
$caughtException = $exception;
1063+
}
1064+
1065+
$this->assertInstanceOf(ValidationException::class, $caughtException);
1066+
$this->assertEquals(400, $caughtException->getCode());
1067+
$this->assertGreaterThanOrEqual(1, $caughtException->getViolationList()->count());
1068+
1069+
// Valid email array
1070+
$data = $normalizer->denormalize([
1071+
'emailArray' => ['foo@bar.com', 'test@example.org'],
1072+
], ArrayItemsObject::class);
1073+
$this->assertInstanceOf(ArrayItemsObject::class, $data);
1074+
1075+
// Invalid email in array
1076+
$caughtException = null;
1077+
try {
1078+
$normalizer->denormalize([
1079+
'emailArray' => ['not-an-email'],
1080+
], ArrayItemsObject::class);
1081+
} catch (ValidationException $exception) {
1082+
$caughtException = $exception;
1083+
}
1084+
1085+
$this->assertInstanceOf(ValidationException::class, $caughtException);
1086+
$this->assertEquals(400, $caughtException->getCode());
1087+
$this->assertGreaterThanOrEqual(1, $caughtException->getViolationList()->count());
1088+
1089+
// Valid pattern array
1090+
$data = $normalizer->denormalize([
1091+
'patternArray' => ['ABC', 'XYZ'],
1092+
], ArrayItemsObject::class);
1093+
$this->assertInstanceOf(ArrayItemsObject::class, $data);
1094+
1095+
// Invalid pattern in array
1096+
$caughtException = null;
1097+
try {
1098+
$normalizer->denormalize([
1099+
'patternArray' => ['abc'],
1100+
], ArrayItemsObject::class);
1101+
} catch (ValidationException $exception) {
1102+
$caughtException = $exception;
1103+
}
1104+
1105+
$this->assertInstanceOf(ValidationException::class, $caughtException);
1106+
$this->assertEquals(400, $caughtException->getCode());
1107+
$this->assertGreaterThanOrEqual(1, $caughtException->getViolationList()->count());
1108+
1109+
// Empty array is always valid
1110+
$data = $normalizer->denormalize([
1111+
'uuidArray' => [],
1112+
'enumStringArray' => [],
1113+
'constrainedStringArray' => [],
1114+
'constrainedIntegerArray' => [],
1115+
'emailArray' => [],
1116+
'patternArray' => [],
1117+
], ArrayItemsObject::class);
1118+
$this->assertInstanceOf(ArrayItemsObject::class, $data);
1119+
}
9511120
}

Tests/Validation/schema.json

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,55 @@
236236
"minLength": 1
237237
}
238238
}
239+
},
240+
"ArrayItemsObject": {
241+
"type": "object",
242+
"properties": {
243+
"uuidArray": {
244+
"type": "array",
245+
"items": {
246+
"type": "string",
247+
"format": "uuid"
248+
}
249+
},
250+
"enumStringArray": {
251+
"type": "array",
252+
"items": {
253+
"type": "string",
254+
"enum": ["alpha", "beta", "gamma"]
255+
}
256+
},
257+
"constrainedStringArray": {
258+
"type": "array",
259+
"items": {
260+
"type": "string",
261+
"minLength": 2,
262+
"maxLength": 10
263+
}
264+
},
265+
"constrainedIntegerArray": {
266+
"type": "array",
267+
"items": {
268+
"type": "integer",
269+
"minimum": 1,
270+
"maximum": 100
271+
}
272+
},
273+
"emailArray": {
274+
"type": "array",
275+
"items": {
276+
"type": "string",
277+
"format": "email"
278+
}
279+
},
280+
"patternArray": {
281+
"type": "array",
282+
"items": {
283+
"type": "string",
284+
"pattern": "^[A-Z]{3}$"
285+
}
286+
}
287+
}
239288
}
240289
}
241290
}

0 commit comments

Comments
 (0)