diff --git a/CHANGELOG.md b/CHANGELOG.md
index b7d086b5bb5..ea73b393bdc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,7 @@
## 2.7.0
+* JSON Schema: Add support for generating property schema with Collection restriction (#4182)
* JSON Schema: Add support for generating property schema format for Url and Hostname (#4185)
* JSON Schema: Add support for generating property schema with Count restriction (#4186)
* JSON Schema: Manage Compound constraint when generating property metadata (#4180)
diff --git a/src/Bridge/Symfony/Bundle/Resources/config/validator.xml b/src/Bridge/Symfony/Bundle/Resources/config/validator.xml
index 165eee08e50..d05525f5e6c 100644
--- a/src/Bridge/Symfony/Bundle/Resources/config/validator.xml
+++ b/src/Bridge/Symfony/Bundle/Resources/config/validator.xml
@@ -21,6 +21,11 @@
+
+
+
+
+
diff --git a/src/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaCollectionRestriction.php b/src/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaCollectionRestriction.php
new file mode 100644
index 00000000000..cf5336820a4
--- /dev/null
+++ b/src/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaCollectionRestriction.php
@@ -0,0 +1,96 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction;
+
+use ApiPlatform\Core\Metadata\Property\PropertyMetadata;
+use Symfony\Component\Validator\Constraint;
+use Symfony\Component\Validator\Constraints\Collection;
+use Symfony\Component\Validator\Constraints\Optional;
+use Symfony\Component\Validator\Constraints\Required;
+
+/**
+ * @author Tomas Norkūnas
+ */
+final class PropertySchemaCollectionRestriction implements PropertySchemaRestrictionMetadataInterface
+{
+ /**
+ * @var iterable
+ */
+ private $restrictionsMetadata;
+
+ /**
+ * @param iterable $restrictionsMetadata
+ */
+ public function __construct(iterable $restrictionsMetadata = [])
+ {
+ $this->restrictionsMetadata = $restrictionsMetadata;
+ }
+
+ /**
+ * {@inheritdoc}
+ *
+ * @param Collection $constraint
+ */
+ public function create(Constraint $constraint, PropertyMetadata $propertyMetadata): array
+ {
+ $restriction = [
+ 'type' => 'object',
+ 'properties' => [],
+ 'additionalProperties' => $constraint->allowExtraFields,
+ ];
+ $required = [];
+
+ foreach ($constraint->fields as $field => $baseConstraint) {
+ /** @var Required|Optional $baseConstraint */
+ if ($baseConstraint instanceof Required) {
+ $required[] = $field;
+ }
+
+ $restriction['properties'][$field] = $this->mergeConstraintRestrictions($baseConstraint, $propertyMetadata);
+ }
+
+ if ($required) {
+ $restriction['required'] = $required;
+ }
+
+ return $restriction;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function supports(Constraint $constraint, PropertyMetadata $propertyMetadata): bool
+ {
+ return $constraint instanceof Collection;
+ }
+
+ /**
+ * @param Required|Optional $constraint
+ */
+ private function mergeConstraintRestrictions(Constraint $constraint, PropertyMetadata $propertyMetadata): array
+ {
+ $propertyRestrictions = [];
+ $nestedConstraints = method_exists($constraint, 'getNestedContraints') ? $constraint->getNestedContraints() : $constraint->constraints;
+
+ foreach ($nestedConstraints as $nestedConstraint) {
+ foreach ($this->restrictionsMetadata as $restrictionMetadata) {
+ if ($restrictionMetadata->supports($nestedConstraint, $propertyMetadata) && !empty($nestedConstraintRestriction = $restrictionMetadata->create($nestedConstraint, $propertyMetadata))) {
+ $propertyRestrictions[] = $nestedConstraintRestriction;
+ }
+ }
+ }
+
+ return array_merge([], ...$propertyRestrictions);
+ }
+}
diff --git a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php
index ab1ce1ef375..1f5b861ee64 100644
--- a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php
+++ b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php
@@ -1358,6 +1358,7 @@ private function getBaseContainerBuilderProphecyWithoutDefaultMetadataLoading(ar
'api_platform.metadata.property.metadata_factory.annotation',
'api_platform.metadata.property.metadata_factory.validator',
'api_platform.metadata.property_schema.choice_restriction',
+ 'api_platform.metadata.property_schema.collection_restriction',
'api_platform.metadata.property_schema.count_restriction',
'api_platform.metadata.property_schema.length_restriction',
'api_platform.metadata.property_schema.one_of_restriction',
diff --git a/tests/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaCollectionRestrictionTest.php b/tests/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaCollectionRestrictionTest.php
new file mode 100644
index 00000000000..80842e2dd9a
--- /dev/null
+++ b/tests/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaCollectionRestrictionTest.php
@@ -0,0 +1,121 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace ApiPlatform\Core\Tests\Bridge\Symfony\Validator\Metadata\Property\Restriction;
+
+use ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaCollectionRestriction;
+use ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaFormat;
+use ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaLengthRestriction;
+use ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaRegexRestriction;
+use ApiPlatform\Core\Metadata\Property\PropertyMetadata;
+use ApiPlatform\Core\Tests\ProphecyTrait;
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\Validator\Constraint;
+use Symfony\Component\Validator\Constraints\Collection;
+use Symfony\Component\Validator\Constraints\Email;
+use Symfony\Component\Validator\Constraints\Length;
+use Symfony\Component\Validator\Constraints\NotBlank;
+use Symfony\Component\Validator\Constraints\NotNull;
+use Symfony\Component\Validator\Constraints\Optional;
+use Symfony\Component\Validator\Constraints\Positive;
+use Symfony\Component\Validator\Constraints\Regex;
+use Symfony\Component\Validator\Constraints\Required;
+
+/**
+ * @author Tomas Norkūnas
+ */
+final class PropertySchemaCollectionRestrictionTest extends TestCase
+{
+ use ProphecyTrait;
+
+ private $propertySchemaCollectionRestriction;
+
+ protected function setUp(): void
+ {
+ $this->propertySchemaCollectionRestriction = new PropertySchemaCollectionRestriction([
+ new PropertySchemaLengthRestriction(),
+ new PropertySchemaRegexRestriction(),
+ new PropertySchemaFormat(),
+ new PropertySchemaCollectionRestriction(),
+ ]);
+ }
+
+ /**
+ * @dataProvider supportsProvider
+ */
+ public function testSupports(Constraint $constraint, PropertyMetadata $propertyMetadata, bool $expectedResult): void
+ {
+ self::assertSame($expectedResult, $this->propertySchemaCollectionRestriction->supports($constraint, $propertyMetadata));
+ }
+
+ public function supportsProvider(): \Generator
+ {
+ yield 'supported' => [new Collection(['fields' => []]), new PropertyMetadata(), true];
+
+ yield 'not supported' => [new Positive(), new PropertyMetadata(), false];
+ }
+
+ /**
+ * @dataProvider createProvider
+ */
+ public function testCreate(Constraint $constraint, PropertyMetadata $propertyMetadata, array $expectedResult): void
+ {
+ self::assertSame($expectedResult, $this->propertySchemaCollectionRestriction->create($constraint, $propertyMetadata));
+ }
+
+ public function createProvider(): \Generator
+ {
+ yield 'empty' => [new Collection(['fields' => []]), new PropertyMetadata(), ['type' => 'object', 'properties' => [], 'additionalProperties' => false]];
+
+ yield 'with fields' => [
+ new Collection([
+ 'allowExtraFields' => true,
+ 'fields' => [
+ 'name' => new Required([
+ new NotBlank(),
+ ]),
+ 'email' => [
+ new NotNull(),
+ new Length(['min' => 2, 'max' => 255]),
+ new Email(['mode' => Email::VALIDATION_MODE_LOOSE]),
+ ],
+ 'phone' => new Optional([
+ new \Symfony\Component\Validator\Constraints\Type(['type' => 'string']),
+ new Regex(['pattern' => '/^[+]*[(]{0,1}[0-9]{1,4}[)]{0,1}[-\s\.\/0-9]*$/']),
+ ]),
+ 'age' => new Optional([
+ new \Symfony\Component\Validator\Constraints\Type(['type' => 'int']),
+ ]),
+ 'social' => new Collection([
+ 'fields' => [
+ 'githubUsername' => new NotNull(),
+ ],
+ ]),
+ ],
+ ]),
+ new PropertyMetadata(),
+ [
+ 'type' => 'object',
+ 'properties' => [
+ 'name' => [],
+ 'email' => ['format' => 'email'],
+ 'phone' => ['pattern' => '^(?:[+]*[(]{0,1}[0-9]{1,4}[)]{0,1}[-\s\./0-9]*)$'],
+ 'age' => [],
+ 'social' => ['type' => 'object', 'properties' => ['githubUsername' => []], 'additionalProperties' => false, 'required' => ['githubUsername']],
+ ],
+ 'additionalProperties' => true,
+ 'required' => ['name', 'email', 'social'],
+ ],
+ ];
+ }
+}
diff --git a/tests/Bridge/Symfony/Validator/Metadata/Property/ValidatorPropertyMetadataFactoryTest.php b/tests/Bridge/Symfony/Validator/Metadata/Property/ValidatorPropertyMetadataFactoryTest.php
index 570bcd695f0..769b8b523ab 100644
--- a/tests/Bridge/Symfony/Validator/Metadata/Property/ValidatorPropertyMetadataFactoryTest.php
+++ b/tests/Bridge/Symfony/Validator/Metadata/Property/ValidatorPropertyMetadataFactoryTest.php
@@ -14,6 +14,7 @@
namespace ApiPlatform\Core\Tests\Bridge\Symfony\Validator\Metadata\Property;
use ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaChoiceRestriction;
+use ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaCollectionRestriction;
use ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaCountRestriction;
use ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaFormat;
use ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaLengthRestriction;
@@ -25,6 +26,7 @@
use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
use ApiPlatform\Core\Metadata\Property\PropertyMetadata;
use ApiPlatform\Core\Tests\Fixtures\DummyAtLeastOneOfValidatedEntity;
+use ApiPlatform\Core\Tests\Fixtures\DummyCollectionValidatedEntity;
use ApiPlatform\Core\Tests\Fixtures\DummyCompoundValidatedEntity;
use ApiPlatform\Core\Tests\Fixtures\DummyCountValidatedEntity;
use ApiPlatform\Core\Tests\Fixtures\DummyIriWithValidationEntity;
@@ -581,4 +583,67 @@ public function provideCountConstraintCases(): \Generator
yield 'max' => ['property' => 'dummyMax', 'expectedSchema' => ['maxItems' => 10]];
yield 'min/max' => ['property' => 'dummyMinMax', 'expectedSchema' => ['minItems' => 1, 'maxItems' => 10]];
}
+
+ public function testCreateWithPropertyCollectionRestriction(): void
+ {
+ $validatorClassMetadata = new ClassMetadata(DummyCollectionValidatedEntity::class);
+ (new AnnotationLoader(new AnnotationReader()))->loadClassMetadata($validatorClassMetadata);
+
+ $validatorMetadataFactory = $this->prophesize(MetadataFactoryInterface::class);
+ $validatorMetadataFactory->getMetadataFor(DummyCollectionValidatedEntity::class)
+ ->willReturn($validatorClassMetadata)
+ ->shouldBeCalled();
+
+ $decoratedPropertyMetadataFactory = $this->prophesize(PropertyMetadataFactoryInterface::class);
+ $decoratedPropertyMetadataFactory->create(DummyCollectionValidatedEntity::class, 'dummyData', [])->willReturn(
+ new PropertyMetadata(new Type(Type::BUILTIN_TYPE_ARRAY))
+ )->shouldBeCalled();
+
+ $lengthRestriction = new PropertySchemaLengthRestriction();
+ $regexRestriction = new PropertySchemaRegexRestriction();
+ $formatRestriction = new PropertySchemaFormat();
+ $restrictionsMetadata = [
+ $lengthRestriction,
+ $regexRestriction,
+ $formatRestriction,
+ new PropertySchemaCollectionRestriction([
+ $lengthRestriction,
+ $regexRestriction,
+ $formatRestriction,
+ new PropertySchemaCollectionRestriction([
+ $lengthRestriction,
+ $regexRestriction,
+ $formatRestriction,
+ ]),
+ ]),
+ ];
+
+ $validationPropertyMetadataFactory = new ValidatorPropertyMetadataFactory(
+ $validatorMetadataFactory->reveal(),
+ $decoratedPropertyMetadataFactory->reveal(),
+ $restrictionsMetadata
+ );
+
+ $schema = $validationPropertyMetadataFactory->create(DummyCollectionValidatedEntity::class, 'dummyData')->getSchema();
+
+ $this->assertSame([
+ 'type' => 'object',
+ 'properties' => [
+ 'name' => [],
+ 'email' => ['format' => 'email'],
+ 'phone' => ['pattern' => '^(?:[+]*[(]{0,1}[0-9]{1,4}[)]{0,1}[-\s\./0-9]*)$'],
+ 'age' => [],
+ 'social' => [
+ 'type' => 'object',
+ 'properties' => [
+ 'githubUsername' => [],
+ ],
+ 'additionalProperties' => false,
+ 'required' => ['githubUsername'],
+ ],
+ ],
+ 'additionalProperties' => true,
+ 'required' => ['name', 'email', 'social'],
+ ], $schema);
+ }
}
diff --git a/tests/Fixtures/DummyCollectionValidatedEntity.php b/tests/Fixtures/DummyCollectionValidatedEntity.php
new file mode 100644
index 00000000000..93902217521
--- /dev/null
+++ b/tests/Fixtures/DummyCollectionValidatedEntity.php
@@ -0,0 +1,50 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace ApiPlatform\Core\Tests\Fixtures;
+
+use Symfony\Component\Validator\Constraints as Assert;
+
+class DummyCollectionValidatedEntity
+{
+ /**
+ * @var array
+ *
+ * @Assert\Collection(
+ * allowExtraFields=true,
+ * fields={
+ * "name"=@Assert\Required({
+ * @Assert\NotBlank
+ * }),
+ * "email"={
+ * @Assert\NotNull,
+ * @Assert\Length(min=2, max=255),
+ * @Assert\Email(mode=Assert\Email::VALIDATION_MODE_LOOSE)
+ * },
+ * "phone"=@Assert\Optional({
+ * @Assert\Type(type="string"),
+ * @Assert\Regex(pattern="/^[+]*[(]{0,1}[0-9]{1,4}[)]{0,1}[-\s\.\/0-9]*$/")
+ * }),
+ * "age"=@Assert\Optional({
+ * @Assert\Type(type="int")
+ * }),
+ * "social"=@Assert\Collection(
+ * fields={
+ * "githubUsername"=@Assert\NotNull
+ * }
+ * )
+ * }
+ * )
+ */
+ public $dummyData;
+}