From 4911c503217380d0c9152ef9b977da29cc32f6d7 Mon Sep 17 00:00:00 2001 From: Tomas Date: Thu, 1 Apr 2021 13:38:43 +0300 Subject: [PATCH 1/2] Add support for generating property schema with Collection restriction --- CHANGELOG.md | 1 + .../Bundle/Resources/config/validator.xml | 5 + .../PropertySchemaCollectionRestriction.php | 128 ++++++++++++++++++ .../ApiPlatformExtensionTest.php | 1 + ...ropertySchemaCollectionRestrictionTest.php | 121 +++++++++++++++++ .../ValidatorPropertyMetadataFactoryTest.php | 65 +++++++++ .../DummyCollectionValidatedEntity.php | 50 +++++++ 7 files changed, 371 insertions(+) create mode 100644 src/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaCollectionRestriction.php create mode 100644 tests/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaCollectionRestrictionTest.php create mode 100644 tests/Fixtures/DummyCollectionValidatedEntity.php 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..1f69c886b4f --- /dev/null +++ b/src/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaCollectionRestriction.php @@ -0,0 +1,128 @@ + + * + * 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; +use Symfony\Component\Validator\Constraints\Type; + +/** + * @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 = [['type' => 'string']]; + $nestedConstraints = method_exists($constraint, 'getNestedContraints') ? $constraint->getNestedContraints() : $constraint->constraints; + + foreach ($nestedConstraints as $nestedConstraint) { + if ($nestedConstraint instanceof Type) { + /** @var string $nestedType */ + $nestedType = $nestedConstraint->type; + $propertyRestrictions[0]['type'] = $this->getConstraintType($nestedType); + } + + foreach ($this->restrictionsMetadata as $restrictionMetadata) { + if ($restrictionMetadata->supports($nestedConstraint, $propertyMetadata) && !empty($nestedConstraintRestriction = $restrictionMetadata->create($nestedConstraint, $propertyMetadata))) { + $propertyRestrictions[] = $nestedConstraintRestriction; + } + } + } + + return array_merge([], ...$propertyRestrictions); + } + + private function getConstraintType(string $type): string + { + if (\in_array($type, ['bool', 'boolean'], true)) { + return 'boolean'; + } + + if (\in_array($type, ['int', 'integer', 'long'], true)) { + return 'integer'; + } + + if (\in_array($type, ['float', 'double', 'real', 'numeric'], true)) { + return 'number'; + } + + if (\in_array($type, ['array', 'iterable'], true)) { + return 'array'; + } + + if ('object' === $type) { + return 'object'; + } + + return 'string'; + } +} 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..23e44bf6c10 --- /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' => ['type' => 'string'], + 'email' => ['type' => 'string', 'format' => 'email'], + 'phone' => ['type' => 'string', 'pattern' => '[+]*[(]{0,1}[0-9]{1,4}[)]{0,1}[-\s\./0-9]*'], + 'age' => ['type' => 'integer'], + 'social' => ['type' => 'object', 'properties' => ['githubUsername' => ['type' => 'string']], '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..44fc21158ff 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' => ['type' => 'string'], + 'email' => ['type' => 'string', 'format' => 'email'], + 'phone' => ['type' => 'string', 'pattern' => '[+]*[(]{0,1}[0-9]{1,4}[)]{0,1}[-\s\./0-9]*'], + 'age' => ['type' => 'integer'], + 'social' => [ + 'type' => 'object', + 'properties' => [ + 'githubUsername' => ['type' => 'string'], + ], + '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; +} From be30f17071716e8ee78fbda025106fa970fe20ae Mon Sep 17 00:00:00 2001 From: Tomas Date: Tue, 13 Apr 2021 07:33:48 +0300 Subject: [PATCH 2/2] Remove type guessing --- .../PropertySchemaCollectionRestriction.php | 34 +------------------ ...ropertySchemaCollectionRestrictionTest.php | 10 +++--- .../ValidatorPropertyMetadataFactoryTest.php | 10 +++--- 3 files changed, 11 insertions(+), 43 deletions(-) diff --git a/src/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaCollectionRestriction.php b/src/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaCollectionRestriction.php index 1f69c886b4f..cf5336820a4 100644 --- a/src/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaCollectionRestriction.php +++ b/src/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaCollectionRestriction.php @@ -18,7 +18,6 @@ use Symfony\Component\Validator\Constraints\Collection; use Symfony\Component\Validator\Constraints\Optional; use Symfony\Component\Validator\Constraints\Required; -use Symfony\Component\Validator\Constraints\Type; /** * @author Tomas Norkūnas @@ -81,16 +80,10 @@ public function supports(Constraint $constraint, PropertyMetadata $propertyMetad */ private function mergeConstraintRestrictions(Constraint $constraint, PropertyMetadata $propertyMetadata): array { - $propertyRestrictions = [['type' => 'string']]; + $propertyRestrictions = []; $nestedConstraints = method_exists($constraint, 'getNestedContraints') ? $constraint->getNestedContraints() : $constraint->constraints; foreach ($nestedConstraints as $nestedConstraint) { - if ($nestedConstraint instanceof Type) { - /** @var string $nestedType */ - $nestedType = $nestedConstraint->type; - $propertyRestrictions[0]['type'] = $this->getConstraintType($nestedType); - } - foreach ($this->restrictionsMetadata as $restrictionMetadata) { if ($restrictionMetadata->supports($nestedConstraint, $propertyMetadata) && !empty($nestedConstraintRestriction = $restrictionMetadata->create($nestedConstraint, $propertyMetadata))) { $propertyRestrictions[] = $nestedConstraintRestriction; @@ -100,29 +93,4 @@ private function mergeConstraintRestrictions(Constraint $constraint, PropertyMet return array_merge([], ...$propertyRestrictions); } - - private function getConstraintType(string $type): string - { - if (\in_array($type, ['bool', 'boolean'], true)) { - return 'boolean'; - } - - if (\in_array($type, ['int', 'integer', 'long'], true)) { - return 'integer'; - } - - if (\in_array($type, ['float', 'double', 'real', 'numeric'], true)) { - return 'number'; - } - - if (\in_array($type, ['array', 'iterable'], true)) { - return 'array'; - } - - if ('object' === $type) { - return 'object'; - } - - return 'string'; - } } diff --git a/tests/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaCollectionRestrictionTest.php b/tests/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaCollectionRestrictionTest.php index 23e44bf6c10..80842e2dd9a 100644 --- a/tests/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaCollectionRestrictionTest.php +++ b/tests/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaCollectionRestrictionTest.php @@ -107,11 +107,11 @@ public function createProvider(): \Generator [ 'type' => 'object', 'properties' => [ - 'name' => ['type' => 'string'], - 'email' => ['type' => 'string', 'format' => 'email'], - 'phone' => ['type' => 'string', 'pattern' => '[+]*[(]{0,1}[0-9]{1,4}[)]{0,1}[-\s\./0-9]*'], - 'age' => ['type' => 'integer'], - 'social' => ['type' => 'object', 'properties' => ['githubUsername' => ['type' => 'string']], 'additionalProperties' => false, 'required' => ['githubUsername']], + '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 44fc21158ff..769b8b523ab 100644 --- a/tests/Bridge/Symfony/Validator/Metadata/Property/ValidatorPropertyMetadataFactoryTest.php +++ b/tests/Bridge/Symfony/Validator/Metadata/Property/ValidatorPropertyMetadataFactoryTest.php @@ -629,14 +629,14 @@ public function testCreateWithPropertyCollectionRestriction(): void $this->assertSame([ 'type' => 'object', 'properties' => [ - 'name' => ['type' => 'string'], - 'email' => ['type' => 'string', 'format' => 'email'], - 'phone' => ['type' => 'string', 'pattern' => '[+]*[(]{0,1}[0-9]{1,4}[)]{0,1}[-\s\./0-9]*'], - 'age' => ['type' => 'integer'], + 'name' => [], + 'email' => ['format' => 'email'], + 'phone' => ['pattern' => '^(?:[+]*[(]{0,1}[0-9]{1,4}[)]{0,1}[-\s\./0-9]*)$'], + 'age' => [], 'social' => [ 'type' => 'object', 'properties' => [ - 'githubUsername' => ['type' => 'string'], + 'githubUsername' => [], ], 'additionalProperties' => false, 'required' => ['githubUsername'],