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; +}