diff --git a/CHANGELOG.md b/CHANGELOG.md
index 87e7cf1b47b..224ea87c7ba 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,7 @@
## 2.7.0
+* JSON Schema: Add support for generating property schema with Choice restriction (#4162)
* JSON Schema: Add support for generating property schema with Range restriction (#4158)
* JSON Schema: Add support for generating property schema with Unique restriction (#4159)
* **BC**: Change `api_platform.listener.request.add_format` priority from 7 to 28 to execute it before firewall (priority 8) (#3599)
diff --git a/src/Bridge/Symfony/Bundle/Resources/config/validator.xml b/src/Bridge/Symfony/Bundle/Resources/config/validator.xml
index 5752356b0fc..5243f45dfc5 100644
--- a/src/Bridge/Symfony/Bundle/Resources/config/validator.xml
+++ b/src/Bridge/Symfony/Bundle/Resources/config/validator.xml
@@ -17,6 +17,10 @@
+
+
+
+
diff --git a/src/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaChoiceRestriction.php b/src/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaChoiceRestriction.php
new file mode 100644
index 00000000000..c12630177a5
--- /dev/null
+++ b/src/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaChoiceRestriction.php
@@ -0,0 +1,74 @@
+
+ *
+ * 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\PropertyInfo\Type;
+use Symfony\Component\Validator\Constraint;
+use Symfony\Component\Validator\Constraints\Choice;
+
+/**
+ * @author Tomas Norkūnas
+ */
+final class PropertySchemaChoiceRestriction implements PropertySchemaRestrictionMetadataInterface
+{
+ /**
+ * {@inheritdoc}
+ *
+ * @param Choice $constraint
+ */
+ public function create(Constraint $constraint, PropertyMetadata $propertyMetadata): array
+ {
+ $choices = [];
+
+ if (\is_callable($choices = $constraint->callback)) {
+ $choices = $choices();
+ } elseif (\is_array($constraint->choices)) {
+ $choices = $constraint->choices;
+ }
+
+ if (!$choices) {
+ return [];
+ }
+
+ $restriction = [];
+
+ if (!$constraint->multiple) {
+ $restriction['enum'] = $choices;
+
+ return $restriction;
+ }
+
+ $restriction['type'] = 'array';
+ $restriction['items'] = ['type' => Type::BUILTIN_TYPE_STRING === $propertyMetadata->getType()->getBuiltinType() ? 'string' : 'number', 'enum' => $choices];
+
+ if (null !== $constraint->min) {
+ $restriction['minItems'] = $constraint->min;
+ }
+
+ if (null !== $constraint->max) {
+ $restriction['maxItems'] = $constraint->max;
+ }
+
+ return $restriction;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function supports(Constraint $constraint, PropertyMetadata $propertyMetadata): bool
+ {
+ return $constraint instanceof Choice && null !== ($type = $propertyMetadata->getType()) && \in_array($type->getBuiltinType(), [Type::BUILTIN_TYPE_STRING, Type::BUILTIN_TYPE_INT, Type::BUILTIN_TYPE_FLOAT], true);
+ }
+}
diff --git a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php
index 9cb3d8e4367..9708ac6022c 100644
--- a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php
+++ b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php
@@ -1335,6 +1335,7 @@ private function getBaseContainerBuilderProphecyWithoutDefaultMetadataLoading(ar
'api_platform.metadata.extractor.yaml',
'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.length_restriction',
'api_platform.metadata.property_schema.one_of_restriction',
'api_platform.metadata.property_schema.range_restriction',
diff --git a/tests/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaChoiceRestrictionTest.php b/tests/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaChoiceRestrictionTest.php
new file mode 100644
index 00000000000..36fc8aca64f
--- /dev/null
+++ b/tests/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaChoiceRestrictionTest.php
@@ -0,0 +1,94 @@
+
+ *
+ * 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\PropertySchemaChoiceRestriction;
+use ApiPlatform\Core\Metadata\Property\PropertyMetadata;
+use ApiPlatform\Core\Tests\ProphecyTrait;
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\PropertyInfo\Type;
+use Symfony\Component\Validator\Constraint;
+use Symfony\Component\Validator\Constraints\Choice;
+use Symfony\Component\Validator\Constraints\Positive;
+
+/**
+ * @author Tomas Norkūnas
+ */
+final class PropertySchemaChoiceRestrictionTest extends TestCase
+{
+ use ProphecyTrait;
+
+ private $propertySchemaChoiceRestriction;
+
+ protected function setUp(): void
+ {
+ $this->propertySchemaChoiceRestriction = new PropertySchemaChoiceRestriction();
+ }
+
+ /**
+ * @dataProvider supportsProvider
+ */
+ public function testSupports(Constraint $constraint, PropertyMetadata $propertyMetadata, bool $expectedResult): void
+ {
+ self::assertSame($expectedResult, $this->propertySchemaChoiceRestriction->supports($constraint, $propertyMetadata));
+ }
+
+ public function supportsProvider(): \Generator
+ {
+ yield 'supported' => [new Choice(['choices' => ['a', 'b']]), new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING)), true];
+
+ yield 'not supported constraint' => [new Positive(), new PropertyMetadata(), false];
+ yield 'not supported type' => [new Choice(['choices' => [new \stdClass(), new \stdClass()]]), new PropertyMetadata(new Type(Type::BUILTIN_TYPE_OBJECT)), false];
+ }
+
+ /**
+ * @dataProvider createProvider
+ */
+ public function testCreate(Constraint $constraint, PropertyMetadata $propertyMetadata, array $expectedResult): void
+ {
+ self::assertSame($expectedResult, $this->propertySchemaChoiceRestriction->create($constraint, $propertyMetadata));
+ }
+
+ public function createProvider(): \Generator
+ {
+ yield 'single string choice' => [new Choice(['choices' => ['a', 'b']]), new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING)), ['enum' => ['a', 'b']]];
+ yield 'multi string choice' => [new Choice(['choices' => ['a', 'b'], 'multiple' => true]), new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING)), ['type' => 'array', 'items' => ['type' => 'string', 'enum' => ['a', 'b']]]];
+ yield 'multi string choice min' => [new Choice(['choices' => ['a', 'b'], 'multiple' => true, 'min' => 2]), new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING)), ['type' => 'array', 'items' => ['type' => 'string', 'enum' => ['a', 'b']], 'minItems' => 2]];
+ yield 'multi string choice max' => [new Choice(['choices' => ['a', 'b', 'c', 'd'], 'multiple' => true, 'max' => 4]), new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING)), ['type' => 'array', 'items' => ['type' => 'string', 'enum' => ['a', 'b', 'c', 'd']], 'maxItems' => 4]];
+ yield 'multi string choice min/max' => [new Choice(['choices' => ['a', 'b', 'c', 'd'], 'multiple' => true, 'min' => 2, 'max' => 4]), new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING)), ['type' => 'array', 'items' => ['type' => 'string', 'enum' => ['a', 'b', 'c', 'd']], 'minItems' => 2, 'maxItems' => 4]];
+
+ yield 'single int choice' => [new Choice(['choices' => [1, 2]]), new PropertyMetadata(new Type(Type::BUILTIN_TYPE_INT)), ['enum' => [1, 2]]];
+ yield 'multi int choice' => [new Choice(['choices' => [1, 2], 'multiple' => true]), new PropertyMetadata(new Type(Type::BUILTIN_TYPE_INT)), ['type' => 'array', 'items' => ['type' => 'number', 'enum' => [1, 2]]]];
+ yield 'multi int choice min' => [new Choice(['choices' => [1, 2], 'multiple' => true, 'min' => 2]), new PropertyMetadata(new Type(Type::BUILTIN_TYPE_INT)), ['type' => 'array', 'items' => ['type' => 'number', 'enum' => [1, 2]], 'minItems' => 2]];
+ yield 'multi int choice max' => [new Choice(['choices' => [1, 2, 3, 4], 'multiple' => true, 'max' => 4]), new PropertyMetadata(new Type(Type::BUILTIN_TYPE_INT)), ['type' => 'array', 'items' => ['type' => 'number', 'enum' => [1, 2, 3, 4]], 'maxItems' => 4]];
+ yield 'multi int choice min/max' => [new Choice(['choices' => [1, 2, 3, 4], 'multiple' => true, 'min' => 2, 'max' => 4]), new PropertyMetadata(new Type(Type::BUILTIN_TYPE_INT)), ['type' => 'array', 'items' => ['type' => 'number', 'enum' => [1, 2, 3, 4]], 'minItems' => 2, 'maxItems' => 4]];
+
+ yield 'single float choice' => [new Choice(['choices' => [1.1, 2.2]]), new PropertyMetadata(new Type(Type::BUILTIN_TYPE_FLOAT)), ['enum' => [1.1, 2.2]]];
+ yield 'multi float choice' => [new Choice(['choices' => [1.1, 2.2], 'multiple' => true]), new PropertyMetadata(new Type(Type::BUILTIN_TYPE_FLOAT)), ['type' => 'array', 'items' => ['type' => 'number', 'enum' => [1.1, 2.2]]]];
+ yield 'multi float choice min' => [new Choice(['choices' => [1.1, 2.2], 'multiple' => true, 'min' => 2]), new PropertyMetadata(new Type(Type::BUILTIN_TYPE_FLOAT)), ['type' => 'array', 'items' => ['type' => 'number', 'enum' => [1.1, 2.2]], 'minItems' => 2]];
+ yield 'multi float choice max' => [new Choice(['choices' => [1.1, 2.2, 3.3, 4.4], 'multiple' => true, 'max' => 4]), new PropertyMetadata(new Type(Type::BUILTIN_TYPE_FLOAT)), ['type' => 'array', 'items' => ['type' => 'number', 'enum' => [1.1, 2.2, 3.3, 4.4]], 'maxItems' => 4]];
+ yield 'multi float choice min/max' => [new Choice(['choices' => [1.1, 2.2, 3.3, 4.4], 'multiple' => true, 'min' => 2, 'max' => 4]), new PropertyMetadata(new Type(Type::BUILTIN_TYPE_FLOAT)), ['type' => 'array', 'items' => ['type' => 'number', 'enum' => [1.1, 2.2, 3.3, 4.4]], 'minItems' => 2, 'maxItems' => 4]];
+
+ yield 'single choice callback' => [new Choice(['callback' => [ChoiceCallback::class, 'getChoices']]), new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING)), ['enum' => ['a', 'b', 'c', 'd']]];
+ yield 'multi choice callback' => [new Choice(['callback' => [ChoiceCallback::class, 'getChoices'], 'multiple' => true]), new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING)), ['type' => 'array', 'items' => ['type' => 'string', 'enum' => ['a', 'b', 'c', 'd']]]];
+ }
+}
+
+final class ChoiceCallback
+{
+ public static function getChoices(): array
+ {
+ return ['a', 'b', 'c', 'd'];
+ }
+}
diff --git a/tests/Bridge/Symfony/Validator/Metadata/Property/ValidatorPropertyMetadataFactoryTest.php b/tests/Bridge/Symfony/Validator/Metadata/Property/ValidatorPropertyMetadataFactoryTest.php
index 02f2c440a19..b7cfa83f6a2 100644
--- a/tests/Bridge/Symfony/Validator/Metadata/Property/ValidatorPropertyMetadataFactoryTest.php
+++ b/tests/Bridge/Symfony/Validator/Metadata/Property/ValidatorPropertyMetadataFactoryTest.php
@@ -13,6 +13,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\PropertySchemaFormat;
use ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaLengthRestriction;
use ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaOneOfRestriction;
@@ -27,6 +28,7 @@
use ApiPlatform\Core\Tests\Fixtures\DummyRangeValidatedEntity;
use ApiPlatform\Core\Tests\Fixtures\DummySequentiallyValidatedEntity;
use ApiPlatform\Core\Tests\Fixtures\DummyUniqueValidatedEntity;
+use ApiPlatform\Core\Tests\Fixtures\DummyValidatedChoiceEntity;
use ApiPlatform\Core\Tests\Fixtures\DummyValidatedEntity;
use ApiPlatform\Core\Tests\ProphecyTrait;
use Doctrine\Common\Annotations\AnnotationReader;
@@ -473,4 +475,43 @@ public function provideRangeConstraintCases(): \Generator
yield 'max float' => ['type' => new Type(Type::BUILTIN_TYPE_FLOAT), 'property' => 'dummyFloatMax', 'expectedSchema' => ['maximum' => 10.5]];
yield 'min/max float' => ['type' => new Type(Type::BUILTIN_TYPE_FLOAT), 'property' => 'dummyFloatMinMax', 'expectedSchema' => ['minimum' => 1.5, 'maximum' => 10.5]];
}
+
+ /**
+ * @dataProvider provideChoiceConstraintCases
+ */
+ public function testCreateWithPropertyChoiceRestriction(PropertyMetadata $propertyMetadata, string $property, array $expectedSchema): void
+ {
+ $validatorClassMetadata = new ClassMetadata(DummyValidatedChoiceEntity::class);
+ (new AnnotationLoader(new AnnotationReader()))->loadClassMetadata($validatorClassMetadata);
+
+ $validatorMetadataFactory = $this->prophesize(MetadataFactoryInterface::class);
+ $validatorMetadataFactory->getMetadataFor(DummyValidatedChoiceEntity::class)
+ ->willReturn($validatorClassMetadata)
+ ->shouldBeCalled();
+
+ $decoratedPropertyMetadataFactory = $this->prophesize(PropertyMetadataFactoryInterface::class);
+ $decoratedPropertyMetadataFactory->create(DummyValidatedChoiceEntity::class, $property, [])->willReturn(
+ $propertyMetadata
+ )->shouldBeCalled();
+
+ $validationPropertyMetadataFactory = new ValidatorPropertyMetadataFactory(
+ $validatorMetadataFactory->reveal(), $decoratedPropertyMetadataFactory->reveal(),
+ [new PropertySchemaChoiceRestriction()]
+ );
+
+ $schema = $validationPropertyMetadataFactory->create(DummyValidatedChoiceEntity::class, $property)->getSchema();
+
+ $this->assertSame($expectedSchema, $schema);
+ }
+
+ public function provideChoiceConstraintCases(): \Generator
+ {
+ yield 'single choice' => ['propertyMetadata' => new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING)), 'property' => 'dummySingleChoice', 'expectedSchema' => ['enum' => ['a', 'b']]];
+ yield 'single choice callback' => ['propertyMetadata' => new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING)), 'property' => 'dummySingleChoiceCallback', 'expectedSchema' => ['enum' => ['a', 'b', 'c', 'd']]];
+ yield 'multi choice' => ['propertyMetadata' => new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING)), 'property' => 'dummyMultiChoice', 'expectedSchema' => ['type' => 'array', 'items' => ['type' => 'string', 'enum' => ['a', 'b']]]];
+ yield 'multi choice callback' => ['propertyMetadata' => new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING)), 'property' => 'dummyMultiChoiceCallback', 'expectedSchema' => ['type' => 'array', 'items' => ['type' => 'string', 'enum' => ['a', 'b', 'c', 'd']]]];
+ yield 'multi choice min' => ['propertyMetadata' => new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING)), 'property' => 'dummyMultiChoiceMin', 'expectedSchema' => ['type' => 'array', 'items' => ['type' => 'string', 'enum' => ['a', 'b', 'c', 'd']], 'minItems' => 2]];
+ yield 'multi choice max' => ['propertyMetadata' => new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING)), 'property' => 'dummyMultiChoiceMax', 'expectedSchema' => ['type' => 'array', 'items' => ['type' => 'string', 'enum' => ['a', 'b', 'c', 'd']], 'maxItems' => 4]];
+ yield 'multi choice min/max' => ['propertyMetadata' => new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING)), 'property' => 'dummyMultiChoiceMinMax', 'expectedSchema' => ['type' => 'array', 'items' => ['type' => 'string', 'enum' => ['a', 'b', 'c', 'd']], 'minItems' => 2, 'maxItems' => 4]];
+ }
}
diff --git a/tests/Fixtures/DummyValidatedChoiceEntity.php b/tests/Fixtures/DummyValidatedChoiceEntity.php
new file mode 100644
index 00000000000..8c41c04ed1e
--- /dev/null
+++ b/tests/Fixtures/DummyValidatedChoiceEntity.php
@@ -0,0 +1,73 @@
+
+ *
+ * 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 DummyValidatedChoiceEntity
+{
+ /**
+ * @var string
+ *
+ * @Assert\Choice(choices={"a", "b"})
+ */
+ public $dummySingleChoice;
+
+ /**
+ * @var string
+ *
+ * @Assert\Choice(callback={DummyValidatedChoiceEntity::class, "getChoices"})
+ */
+ public $dummySingleChoiceCallback;
+
+ /**
+ * @var string[]
+ *
+ * @Assert\Choice(choices={"a", "b"}, multiple=true)
+ */
+ public $dummyMultiChoice;
+
+ /**
+ * @var string[]
+ *
+ * @Assert\Choice(callback={DummyValidatedChoiceEntity::class, "getChoices"}, multiple=true)
+ */
+ public $dummyMultiChoiceCallback;
+
+ /**
+ * @var string[]
+ *
+ * @Assert\Choice(choices={"a", "b", "c", "d"}, multiple=true, min=2)
+ */
+ public $dummyMultiChoiceMin;
+
+ /**
+ * @var string[]
+ *
+ * @Assert\Choice(choices={"a", "b", "c", "d"}, multiple=true, max=4)
+ */
+ public $dummyMultiChoiceMax;
+
+ /**
+ * @var string[]
+ *
+ * @Assert\Choice(choices={"a", "b", "c", "d"}, multiple=true, min=2, max=4)
+ */
+ public $dummyMultiChoiceMinMax;
+
+ public static function getChoices(): array
+ {
+ return ['a', 'b', 'c', 'd'];
+ }
+}