Skip to content

Commit

Permalink
Add support for generating property schema with Choice restriction
Browse files Browse the repository at this point in the history
  • Loading branch information
norkunas committed Mar 25, 2021
1 parent 9c348d2 commit fa7f475
Show file tree
Hide file tree
Showing 7 changed files with 287 additions and 0 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## 2.7.0

* JSON Schema: Add support for generating property schema with Choice restriction (#4162)
* **BC**: Change `api_platform.listener.request.add_format` priority from 7 to 28 to execute it before firewall (priority 8) (#3599)
* **BC**: Use `@final` annotation in ORM filters (#4109)
* Allow defining `exception_to_status` per operation (#3519)
Expand Down
4 changes: 4 additions & 0 deletions src/Bridge/Symfony/Bundle/Resources/config/validator.xml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@
<argument type="tagged" tag="api_platform.metadata.property_schema_restriction" />
</service>

<service id="api_platform.metadata.property_schema.choice_restriction" class="ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaChoiceRestriction" public="false">
<tag name="api_platform.metadata.property_schema_restriction"/>
</service>

<service id="api_platform.metadata.property_schema.length_restriction" class="ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaLengthRestriction" public="false">
<tag name="api_platform.metadata.property_schema_restriction"/>
</service>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <[email protected]>
*
* 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 <[email protected]>
*/
final class PropertySchemaChoiceRestriction implements PropertySchemaRestrictionMetadataInterface
{
/**
* {@inheritdoc}
*/
public function create(Constraint $constraint, PropertyMetadata $propertyMetadata): array
{
if (!\in_array($builtinType = $propertyMetadata->getType()->getBuiltinType(), [Type::BUILTIN_TYPE_STRING, Type::BUILTIN_TYPE_INT, Type::BUILTIN_TYPE_FLOAT], true)) {
return [];
}

if (\is_callable($choices = $constraint->callback)) {
$choices = $choices();
} elseif (\is_array($constraint->choices)) {
$choices = $constraint->choices;
} else {
$choices = [];
}

if (!$choices) {
return [];
}

if ($constraint->multiple) {
$restriction['type'] = 'array';
$restriction['items'] = ['type' => Type::BUILTIN_TYPE_STRING !== $builtinType ? 'string' : 'number', 'enum' => $choices];

if (null !== $constraint->min) {
$restriction['minItems'] = $constraint->min;
}

if (null !== $constraint->max) {
$restriction['maxItems'] = $constraint->max;
}
} else {
$restriction['enum'] = $choices;
}

return $restriction;
}

/**
* {@inheritdoc}
*/
public function supports(Constraint $constraint, PropertyMetadata $propertyMetadata): bool
{
return $constraint instanceof Choice && null !== $propertyMetadata->getType();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.regex_restriction',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <[email protected]>
*
* 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 <[email protected]>
*/
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' => [new Positive(), new PropertyMetadata(), 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']]]];

yield 'not supported type' => [new Choice(['choices' => [new \stdClass()]]), new PropertyMetadata(new Type(Type::BUILTIN_TYPE_OBJECT)), []];
}
}

final class ChoiceCallback
{
public static function getChoices(): array
{
return ['a', 'b', 'c', 'd'];
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -23,6 +24,7 @@
use ApiPlatform\Core\Tests\Fixtures\DummyAtLeastOneOfValidatedEntity;
use ApiPlatform\Core\Tests\Fixtures\DummyIriWithValidationEntity;
use ApiPlatform\Core\Tests\Fixtures\DummySequentiallyValidatedEntity;
use ApiPlatform\Core\Tests\Fixtures\DummyValidatedChoiceEntity;
use ApiPlatform\Core\Tests\Fixtures\DummyValidatedEntity;
use ApiPlatform\Core\Tests\ProphecyTrait;
use Doctrine\Common\Annotations\AnnotationReader;
Expand Down Expand Up @@ -406,4 +408,43 @@ public function testCreateWithAtLeastOneOfConstraint(): void
['minLength' => 10],
], $schema['oneOf']);
}

/**
* @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]];
}
}
73 changes: 73 additions & 0 deletions tests/Fixtures/DummyValidatedChoiceEntity.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <[email protected]>
*
* 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'];
}
}

0 comments on commit fa7f475

Please sign in to comment.