Merge pull request #1902 from chrisguitarguy/constraint_groups

Respect Constraint Validation Groups When Describing Models
This commit is contained in:
Christopher Davis 2022-06-10 16:15:42 -05:00 committed by GitHub
commit 235963df41
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 452 additions and 20 deletions

View File

@ -31,6 +31,7 @@ final class ConfigurationPass implements CompilerPassInterface
->addArgument(new Reference('form.factory')) ->addArgument(new Reference('form.factory'))
->addArgument(new Reference('annotations.reader')) ->addArgument(new Reference('annotations.reader'))
->addArgument($container->getParameter('nelmio_api_doc.media_types')) ->addArgument($container->getParameter('nelmio_api_doc.media_types'))
->addArgument($container->getParameter('nelmio_api_doc.use_validation_groups'))
->addTag('nelmio_api_doc.model_describer', ['priority' => 100]); ->addTag('nelmio_api_doc.model_describer', ['priority' => 100]);
} }

View File

@ -29,6 +29,10 @@ final class Configuration implements ConfigurationInterface
$rootNode $rootNode
->children() ->children()
->booleanNode('use_validation_groups')
->info('If true, `groups` passed to @Model annotations will be used to limit validation constraints')
->defaultFalse()
->end()
->arrayNode('documentation') ->arrayNode('documentation')
->useAttributeAsKey('key') ->useAttributeAsKey('key')
->info('The documentation used as base') ->info('The documentation used as base')

View File

@ -64,6 +64,7 @@ final class NelmioApiDocExtension extends Extension implements PrependExtensionI
$container->setParameter('nelmio_api_doc.areas', array_keys($config['areas'])); $container->setParameter('nelmio_api_doc.areas', array_keys($config['areas']));
$container->setParameter('nelmio_api_doc.media_types', $config['media_types']); $container->setParameter('nelmio_api_doc.media_types', $config['media_types']);
$container->setParameter('nelmio_api_doc.use_validation_groups', $config['use_validation_groups']);
foreach ($config['areas'] as $area => $areaConfig) { foreach ($config['areas'] as $area => $areaConfig) {
$nameAliases = $this->findNameAliases($config['models']['names'], $area); $nameAliases = $this->findNameAliases($config['models']['names'], $area);
$container->register(sprintf('nelmio_api_doc.generator.%s', $area), ApiDocGenerator::class) $container->register(sprintf('nelmio_api_doc.generator.%s', $area), ApiDocGenerator::class)
@ -176,6 +177,7 @@ final class NelmioApiDocExtension extends Extension implements PrependExtensionI
new Reference('annotations.reader'), new Reference('annotations.reader'),
$config['media_types'], $config['media_types'],
$jmsNamingStrategy, $jmsNamingStrategy,
$container->getParameter('nelmio_api_doc.use_validation_groups'),
]) ])
->addTag('nelmio_api_doc.model_describer', ['priority' => 50]); ->addTag('nelmio_api_doc.model_describer', ['priority' => 50]);

View File

@ -28,14 +28,21 @@ class AnnotationsReader
private $openApiAnnotationsReader; private $openApiAnnotationsReader;
private $symfonyConstraintAnnotationReader; private $symfonyConstraintAnnotationReader;
public function __construct(Reader $annotationsReader, ModelRegistry $modelRegistry, array $mediaTypes) public function __construct(
{ Reader $annotationsReader,
ModelRegistry $modelRegistry,
array $mediaTypes,
bool $useValidationGroups = false
) {
$this->annotationsReader = $annotationsReader; $this->annotationsReader = $annotationsReader;
$this->modelRegistry = $modelRegistry; $this->modelRegistry = $modelRegistry;
$this->phpDocReader = new PropertyPhpDocReader(); $this->phpDocReader = new PropertyPhpDocReader();
$this->openApiAnnotationsReader = new OpenApiAnnotationsReader($annotationsReader, $modelRegistry, $mediaTypes); $this->openApiAnnotationsReader = new OpenApiAnnotationsReader($annotationsReader, $modelRegistry, $mediaTypes);
$this->symfonyConstraintAnnotationReader = new SymfonyConstraintAnnotationReader($annotationsReader); $this->symfonyConstraintAnnotationReader = new SymfonyConstraintAnnotationReader(
$annotationsReader,
$useValidationGroups
);
} }
public function updateDefinition(\ReflectionClass $reflectionClass, OA\Schema $schema): UpdateClassDefinitionResult public function updateDefinition(\ReflectionClass $reflectionClass, OA\Schema $schema): UpdateClassDefinitionResult
@ -57,7 +64,7 @@ class AnnotationsReader
{ {
$this->openApiAnnotationsReader->updateProperty($reflection, $property, $serializationGroups); $this->openApiAnnotationsReader->updateProperty($reflection, $property, $serializationGroups);
$this->phpDocReader->updateProperty($reflection, $property); $this->phpDocReader->updateProperty($reflection, $property);
$this->symfonyConstraintAnnotationReader->updateProperty($reflection, $property); $this->symfonyConstraintAnnotationReader->updateProperty($reflection, $property, $serializationGroups);
} }
/** /**

View File

@ -33,9 +33,15 @@ class SymfonyConstraintAnnotationReader
*/ */
private $schema; private $schema;
public function __construct(Reader $annotationsReader) /**
* @var bool
*/
private $useValidationGroups;
public function __construct(Reader $annotationsReader, bool $useValidationGroups=false)
{ {
$this->annotationsReader = $annotationsReader; $this->annotationsReader = $annotationsReader;
$this->useValidationGroups = $useValidationGroups;
} }
/** /**
@ -43,9 +49,9 @@ class SymfonyConstraintAnnotationReader
* *
* @param \ReflectionProperty|\ReflectionMethod $reflection * @param \ReflectionProperty|\ReflectionMethod $reflection
*/ */
public function updateProperty($reflection, OA\Property $property): void public function updateProperty($reflection, OA\Property $property, ?array $validationGroups = null): void
{ {
foreach ($this->getAnnotations($reflection) as $outerAnnotation) { foreach ($this->getAnnotations($reflection, $validationGroups) as $outerAnnotation) {
$innerAnnotations = $outerAnnotation instanceof Assert\Compound $innerAnnotations = $outerAnnotation instanceof Assert\Compound
? $outerAnnotation->constraints ? $outerAnnotation->constraints
: [$outerAnnotation]; : [$outerAnnotation];
@ -176,7 +182,23 @@ class SymfonyConstraintAnnotationReader
/** /**
* @param \ReflectionProperty|\ReflectionMethod $reflection * @param \ReflectionProperty|\ReflectionMethod $reflection
*/ */
private function getAnnotations($reflection): \Traversable private function getAnnotations($reflection, ?array $validationGroups): iterable
{
foreach ($this->locateAnnotations($reflection) as $annotation) {
if (!$annotation instanceof Constraint) {
continue;
}
if (!$this->useValidationGroups || $this->isConstraintInGroup($annotation, $validationGroups)) {
yield $annotation;
}
}
}
/**
* @param \ReflectionProperty|\ReflectionMethod $reflection
*/
private function locateAnnotations($reflection): \Traversable
{ {
if (\PHP_VERSION_ID >= 80000 && class_exists(Constraint::class)) { if (\PHP_VERSION_ID >= 80000 && class_exists(Constraint::class)) {
foreach ($reflection->getAttributes(Constraint::class, \ReflectionAttribute::IS_INSTANCEOF) as $attribute) { foreach ($reflection->getAttributes(Constraint::class, \ReflectionAttribute::IS_INSTANCEOF) as $attribute) {
@ -190,4 +212,20 @@ class SymfonyConstraintAnnotationReader
yield from $this->annotationsReader->getMethodAnnotations($reflection); yield from $this->annotationsReader->getMethodAnnotations($reflection);
} }
} }
/**
* Check to see if the given constraint is in the provided serialization groups.
*
* If no groups are provided the validator would run in the Constraint::DEFAULT_GROUP,
* and constraints without any `groups` passed to them would be in that same
* default group. So even with a null $validationGroups passed here there still
* has to be a check on the default group.
*/
private function isConstraintInGroup(Constraint $annotation, ?array $validationGroups): bool
{
return count(array_intersect(
$validationGroups ?: [Constraint::DEFAULT_GROUP],
$annotation->groups
)) > 0;
}
} }

View File

@ -40,9 +40,14 @@ final class FormModelDescriber implements ModelDescriberInterface, ModelRegistry
private $formFactory; private $formFactory;
private $doctrineReader; private $doctrineReader;
private $mediaTypes; private $mediaTypes;
private $useValidationGroups;
public function __construct(FormFactoryInterface $formFactory = null, Reader $reader = null, array $mediaTypes = null) public function __construct(
{ FormFactoryInterface $formFactory = null,
Reader $reader = null,
array $mediaTypes = null,
bool $useValidationGroups = false
) {
$this->formFactory = $formFactory; $this->formFactory = $formFactory;
$this->doctrineReader = $reader; $this->doctrineReader = $reader;
if (null === $reader) { if (null === $reader) {
@ -54,6 +59,7 @@ final class FormModelDescriber implements ModelDescriberInterface, ModelRegistry
@trigger_error(sprintf('Not passing media types to the constructor of %s is deprecated since version 4.1 and won\'t be allowed in version 5.', self::class), E_USER_DEPRECATED); @trigger_error(sprintf('Not passing media types to the constructor of %s is deprecated since version 4.1 and won\'t be allowed in version 5.', self::class), E_USER_DEPRECATED);
} }
$this->mediaTypes = $mediaTypes; $this->mediaTypes = $mediaTypes;
$this->useValidationGroups = $useValidationGroups;
} }
public function describe(Model $model, OA\Schema $schema) public function describe(Model $model, OA\Schema $schema)
@ -67,7 +73,12 @@ final class FormModelDescriber implements ModelDescriberInterface, ModelRegistry
$class = $model->getType()->getClassName(); $class = $model->getType()->getClassName();
$annotationsReader = new AnnotationsReader($this->doctrineReader, $this->modelRegistry, $this->mediaTypes); $annotationsReader = new AnnotationsReader(
$this->doctrineReader,
$this->modelRegistry,
$this->mediaTypes,
$this->useValidationGroups
);
$classResult = $annotationsReader->updateDefinition(new \ReflectionClass($class), $schema); $classResult = $annotationsReader->updateDefinition(new \ReflectionClass($class), $schema);
if (!$classResult->shouldDescribeModelProperties()) { if (!$classResult->shouldDescribeModelProperties()) {

View File

@ -50,16 +50,23 @@ class JMSModelDescriber implements ModelDescriberInterface, ModelRegistryAwareIn
*/ */
private $propertyTypeUseGroupsCache = []; private $propertyTypeUseGroupsCache = [];
/**
* @var bool
*/
private $useValidationGroups;
public function __construct( public function __construct(
MetadataFactoryInterface $factory, MetadataFactoryInterface $factory,
Reader $reader, Reader $reader,
array $mediaTypes, array $mediaTypes,
?PropertyNamingStrategyInterface $namingStrategy = null ?PropertyNamingStrategyInterface $namingStrategy = null,
bool $useValidationGroups = false
) { ) {
$this->factory = $factory; $this->factory = $factory;
$this->namingStrategy = $namingStrategy; $this->namingStrategy = $namingStrategy;
$this->doctrineReader = $reader; $this->doctrineReader = $reader;
$this->mediaTypes = $mediaTypes; $this->mediaTypes = $mediaTypes;
$this->useValidationGroups = $useValidationGroups;
} }
/** /**
@ -73,7 +80,12 @@ class JMSModelDescriber implements ModelDescriberInterface, ModelRegistryAwareIn
throw new \InvalidArgumentException(sprintf('No metadata found for class %s.', $className)); throw new \InvalidArgumentException(sprintf('No metadata found for class %s.', $className));
} }
$annotationsReader = new AnnotationsReader($this->doctrineReader, $this->modelRegistry, $this->mediaTypes); $annotationsReader = new AnnotationsReader(
$this->doctrineReader,
$this->modelRegistry,
$this->mediaTypes,
$this->useValidationGroups
);
$classResult = $annotationsReader->updateDefinition(new \ReflectionClass($className), $schema); $classResult = $annotationsReader->updateDefinition(new \ReflectionClass($className), $schema);
if (!$classResult->shouldDescribeModelProperties()) { if (!$classResult->shouldDescribeModelProperties()) {

View File

@ -41,19 +41,23 @@ class ObjectModelDescriber implements ModelDescriberInterface, ModelRegistryAwar
private $mediaTypes; private $mediaTypes;
/** @var NameConverterInterface[] */ /** @var NameConverterInterface[] */
private $nameConverter; private $nameConverter;
/** @var bool */
private $useValidationGroups;
public function __construct( public function __construct(
PropertyInfoExtractorInterface $propertyInfo, PropertyInfoExtractorInterface $propertyInfo,
Reader $reader, Reader $reader,
iterable $propertyDescribers, iterable $propertyDescribers,
array $mediaTypes, array $mediaTypes,
NameConverterInterface $nameConverter = null NameConverterInterface $nameConverter = null,
bool $useValidationGroups = false
) { ) {
$this->propertyInfo = $propertyInfo; $this->propertyInfo = $propertyInfo;
$this->doctrineReader = $reader; $this->doctrineReader = $reader;
$this->propertyDescribers = $propertyDescribers; $this->propertyDescribers = $propertyDescribers;
$this->mediaTypes = $mediaTypes; $this->mediaTypes = $mediaTypes;
$this->nameConverter = $nameConverter; $this->nameConverter = $nameConverter;
$this->useValidationGroups = $useValidationGroups;
} }
public function describe(Model $model, OA\Schema $schema) public function describe(Model $model, OA\Schema $schema)
@ -67,7 +71,12 @@ class ObjectModelDescriber implements ModelDescriberInterface, ModelRegistryAwar
} }
$reflClass = new \ReflectionClass($class); $reflClass = new \ReflectionClass($class);
$annotationsReader = new AnnotationsReader($this->doctrineReader, $this->modelRegistry, $this->mediaTypes); $annotationsReader = new AnnotationsReader(
$this->doctrineReader,
$this->modelRegistry,
$this->mediaTypes,
$this->useValidationGroups
);
$classResult = $annotationsReader->updateDefinition($reflClass, $schema); $classResult = $annotationsReader->updateDefinition($reflClass, $schema);
if (!$classResult->shouldDescribeModelProperties()) { if (!$classResult->shouldDescribeModelProperties()) {

View File

@ -75,6 +75,7 @@
<argument type="tagged" tag="nelmio_api_doc.object_model.property_describer" /> <argument type="tagged" tag="nelmio_api_doc.object_model.property_describer" />
<argument /> <argument />
<argument type="service" id="serializer.name_converter.metadata_aware" on-invalid="ignore" /> <argument type="service" id="serializer.name_converter.metadata_aware" on-invalid="ignore" />
<argument>%nelmio_api_doc.use_validation_groups%</argument>
<tag name="nelmio_api_doc.model_describer" /> <tag name="nelmio_api_doc.model_describer" />
</service> </service>

View File

@ -235,7 +235,7 @@ The normal PHPDoc block on the controller method is used for the summary and des
However, unlike in those examples, when using this bundle you don't need to specify paths and you can easily document models as well as some However, unlike in those examples, when using this bundle you don't need to specify paths and you can easily document models as well as some
other properties described below as they can be automatically be documented using the Symfony integration. other properties described below as they can be automatically be documented using the Symfony integration.
Use models Use Models
---------- ----------
As shown in the example above, the bundle provides the ``@Model`` annotation. As shown in the example above, the bundle provides the ``@Model`` annotation.
@ -290,6 +290,92 @@ This annotation has two options:
content: new Model(type: User::class, groups: ['non_sensitive_data']) content: new Model(type: User::class, groups: ['non_sensitive_data'])
)] )]
* ``groups`` may also be used to specify the constraint validation groups used
(de)serialize your model, but this must be enabled in configuration::
.. code-block:: yaml
nelmio_api_doc:
use_validation_groups: true
# ...
With this enabled, groups set in the model will apply to both serializer
properties and validator constraints. Take the model class below:
.. configuration-block::
.. code-block:: php-annotations
use Symfony\Component\Serializer\Annotation\Group;
use Symfony\Component\Validator\Constraints as Assert;
class UserDto
{
/**
* @Group({"default", "create", "update"})
* @Assert\NotBlank(groups={"default", "create"})
*/
public string $username;
}
.. code-block:: php-attributes
use Symfony\Component\Serializer\Annotation\Group;
use Symfony\Component\Validator\Constraints as Assert;
class UserDto
{
#[Group(["default", "create", "update"])
#[Assert\NotBlank(groups: ["default", "create"])
public string $username;
}
The ``NotBlank`` constraint will apply only to the ``default`` and ``create``
group, but not ``update``. In more practical terms: the `username` property
would show as ``required`` for both model create and default, but not update.
When using code generators to build API clients, this often translates into
client side validation and types. ``NotBlank`` adding ``required`` will cause
that property type to not be nullable, for example.
.. configuration-block::
.. code-block:: php-annotations
use OpenApi\Annotations as OA;
/**
* shows `username` as `required` in the OpenAPI schema (not nullable)
* @OA\Response(
* response=200,
*     @Model(type=UserDto::class, groups={"default"})
* )
*/
/**
* Similarly, this will make the username `required` in the create
* schema
* @OA\RequestBody(@Model(type=UserDto::class, groups={"create"}))
*/
/**
* But for updates, the `username` property will not be required
* @OA\RequestBody(@Model(type=UserDto::class, groups={"update"}))
*/
.. code-block:: php-attributes
use OpenApi\Attributes as OA;
// shows `username` as `required` in the OpenAPI schema (not nullable)
#[OA\Response(response: 200, content: new Model(type: UserDto::class, groups: ["default"]))]
// Similarly, this will make the username `required` in the create schema
#[OA\RequestBody(new Model(type: UserDto::class, groups: ["create"]))]
// But for updates, the `username` property will not be required
#[OA\RequestBody(new Model(type: UserDto::class, groups: ["update"]))]
.. tip:: .. tip::
When used at the root of ``@OA\Response`` and ``@OA\Parameter``, ``@Model`` is automatically nested When used at the root of ``@OA\Response`` and ``@OA\Parameter``, ``@Model`` is automatically nested

View File

@ -21,6 +21,7 @@ use Nelmio\ApiDocBundle\Tests\Functional\Entity\EntityWithAlternateType;
use Nelmio\ApiDocBundle\Tests\Functional\Entity\EntityWithObjectType; use Nelmio\ApiDocBundle\Tests\Functional\Entity\EntityWithObjectType;
use Nelmio\ApiDocBundle\Tests\Functional\Entity\EntityWithRef; use Nelmio\ApiDocBundle\Tests\Functional\Entity\EntityWithRef;
use Nelmio\ApiDocBundle\Tests\Functional\Entity\SymfonyConstraints; use Nelmio\ApiDocBundle\Tests\Functional\Entity\SymfonyConstraints;
use Nelmio\ApiDocBundle\Tests\Functional\Entity\SymfonyConstraintsWithValidationGroups;
use Nelmio\ApiDocBundle\Tests\Functional\Entity\SymfonyDiscriminator; use Nelmio\ApiDocBundle\Tests\Functional\Entity\SymfonyDiscriminator;
use Nelmio\ApiDocBundle\Tests\Functional\Entity\User; use Nelmio\ApiDocBundle\Tests\Functional\Entity\User;
use Nelmio\ApiDocBundle\Tests\Functional\Form\DummyType; use Nelmio\ApiDocBundle\Tests\Functional\Form\DummyType;
@ -278,6 +279,18 @@ class ApiController80
{ {
} }
/**
* @Route("/swagger/symfonyConstraintsWithValidationGroups", methods={"GET"})
* @OA\Response(
* response="201",
* description="Used for symfony constraints with validation groups test",
* @Model(type=SymfonyConstraintsWithValidationGroups::class, groups={"test"})
* )
*/
public function symfonyConstraintsWithGroupsAction()
{
}
/** /**
* @Route("/alternate-entity-type", methods={"GET", "POST"}) * @Route("/alternate-entity-type", methods={"GET", "POST"})
* *

View File

@ -19,7 +19,7 @@ class SymfonyConstraints
/** /**
* @var int * @var int
* *
* @Assert\NotBlank() * @Assert\NotBlank(groups={"test"})
*/ */
private $propertyNotBlank; private $propertyNotBlank;

View File

@ -0,0 +1,34 @@
<?php
/*
* This file is part of the NelmioApiDocBundle package.
*
* (c) Nelmio
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Nelmio\ApiDocBundle\Tests\Functional\Entity;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;
class SymfonyConstraintsWithValidationGroups
{
/**
* @var int
*
* @Groups("test")
* @Assert\NotBlank(groups={"test"})
* @Assert\Range(min=1, max=100)
*/
public $property;
/**
* @var int
*
* @Assert\Range(min=1, max=100)
*/
public $propertyInDefaultGroup;
}

View File

@ -431,8 +431,8 @@ class FunctionalTest extends WebTestCase
'properties' => [ 'properties' => [
'propertyNotBlank' => [ 'propertyNotBlank' => [
'type' => 'integer', 'type' => 'integer',
'maxItems' => '10', 'maxItems' => 10,
'minItems' => '0', 'minItems' => 0,
], ],
'propertyNotNull' => [ 'propertyNotNull' => [
'type' => 'integer', 'type' => 'integer',

View File

@ -21,6 +21,7 @@ use Nelmio\ApiDocBundle\Tests\Functional\Entity\BazingaUser;
use Nelmio\ApiDocBundle\Tests\Functional\Entity\JMSComplex; use Nelmio\ApiDocBundle\Tests\Functional\Entity\JMSComplex;
use Nelmio\ApiDocBundle\Tests\Functional\Entity\NestedGroup\JMSPicture; use Nelmio\ApiDocBundle\Tests\Functional\Entity\NestedGroup\JMSPicture;
use Nelmio\ApiDocBundle\Tests\Functional\Entity\PrivateProtectedExposure; use Nelmio\ApiDocBundle\Tests\Functional\Entity\PrivateProtectedExposure;
use Nelmio\ApiDocBundle\Tests\Functional\Entity\SymfonyConstraintsWithValidationGroups;
use Nelmio\ApiDocBundle\Tests\Functional\ModelDescriber\VirtualTypeClassDoesNotExistsHandlerDefinedDescriber; use Nelmio\ApiDocBundle\Tests\Functional\ModelDescriber\VirtualTypeClassDoesNotExistsHandlerDefinedDescriber;
use Sensio\Bundle\FrameworkExtraBundle\SensioFrameworkExtraBundle; use Sensio\Bundle\FrameworkExtraBundle\SensioFrameworkExtraBundle;
use Symfony\Bundle\FrameworkBundle\FrameworkBundle; use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
@ -38,6 +39,7 @@ class TestKernel extends Kernel
const USE_JMS = 1; const USE_JMS = 1;
const USE_BAZINGA = 2; const USE_BAZINGA = 2;
const ERROR_ARRAY_ITEMS = 4; const ERROR_ARRAY_ITEMS = 4;
const USE_VALIDATION_GROUPS = 8;
use MicroKernelTrait; use MicroKernelTrait;
@ -176,6 +178,7 @@ class TestKernel extends Kernel
// Filter routes // Filter routes
$c->loadFromExtension('nelmio_api_doc', [ $c->loadFromExtension('nelmio_api_doc', [
'use_validation_groups' => boolval($this->flags & self::USE_VALIDATION_GROUPS),
'documentation' => [ 'documentation' => [
'servers' => [ // from https://github.com/nelmio/NelmioApiDocBundle/issues/1691 'servers' => [ // from https://github.com/nelmio/NelmioApiDocBundle/issues/1691
[ [
@ -280,6 +283,16 @@ class TestKernel extends Kernel
'type' => JMSComplex::class, 'type' => JMSComplex::class,
'groups' => null, 'groups' => null,
], ],
[
'alias' => 'SymfonyConstraintsTestGroup',
'type' => SymfonyConstraintsWithValidationGroups::class,
'groups' => ['test'],
],
[
'alias' => 'SymfonyConstraintsDefaultGroup',
'type' => SymfonyConstraintsWithValidationGroups::class,
'groups' => null,
],
], ],
], ],
]); ]);

View File

@ -0,0 +1,79 @@
<?php
/*
* This file is part of the NelmioApiDocBundle package.
*
* (c) Nelmio
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Nelmio\ApiDocBundle\Tests\Functional;
use Symfony\Component\HttpKernel\KernelInterface;
class ValidationGroupsFunctionalTest extends WebTestCase
{
protected static function createKernel(array $options = []): KernelInterface
{
return new TestKernel(TestKernel::USE_VALIDATION_GROUPS);
}
protected function setUp(): void
{
parent::setUp();
static::createClient([], ['HTTP_HOST' => 'api.example.com']);
}
public function testConstraintGroupsAreRespectedWhenDescribingModels()
{
$expected = [
'required' => [
'property',
],
'properties' => [
'property' => [
'type' => 'integer',
// the min/max constraint is in the default group only and shouldn't
// be read here with validation groups turned on
],
],
'type' => 'object',
'schema' => 'SymfonyConstraintsTestGroup',
];
$this->assertEquals(
$expected,
json_decode($this->getModel('SymfonyConstraintsTestGroup')->toJson(), true)
);
}
public function testConstraintDefaultGroupsAreRespectedWhenReadingAnnotations()
{
$expected = [
'properties' => [
'property' => [
'type' => 'integer',
// min/max will be read here as they are in th e default group
'maximum' => 100,
'minimum' => 1,
],
'propertyInDefaultGroup' => [
'type' => 'integer',
// min/max will be read here as they are in th e default group
'maximum' => 100,
'minimum' => 1,
],
],
'type' => 'object',
'schema' => 'SymfonyConstraintsDefaultGroup',
];
$this->assertEquals(
$expected,
json_decode($this->getModel('SymfonyConstraintsDefaultGroup')->toJson(), true)
);
}
}

View File

@ -18,6 +18,7 @@ use Nelmio\ApiDocBundle\Tests\ModelDescriber\Annotations\Fixture as CustomAssert
use OpenApi\Annotations as OA; use OpenApi\Annotations as OA;
use OpenApi\Generator; use OpenApi\Generator;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Constraints as Assert;
class SymfonyConstraintAnnotationReaderTest extends TestCase class SymfonyConstraintAnnotationReaderTest extends TestCase
@ -424,4 +425,125 @@ class SymfonyConstraintAnnotationReaderTest extends TestCase
}]; }];
} }
} }
/**
* re-using another provider here, since all constraints land in the default
* group when `group={"someGroup"}` is not set.
*
* @group https://github.com/nelmio/NelmioApiDocBundle/issues/1857
* @dataProvider provideRangeConstraintDoesNotSetMinimumIfMinIsNotSet
*/
public function testReaderWithValidationGroupsEnabledChecksForDefaultGroupWhenNoSerializationGroupsArePassed($entity)
{
$schema = new OA\Schema([]);
$schema->merge([new OA\Property(['property' => 'property1'])]);
$reader = $this->createConstraintReaderWithValidationGroupsEnabled();
$reader->setSchema($schema);
// no serialization groups passed here
$reader->updateProperty(
new \ReflectionProperty($entity, 'property1'),
$schema->properties[0]
);
$this->assertSame(10, $schema->properties[0]->maximum, 'should have read constraints in the default group');
}
/**
* @group https://github.com/nelmio/NelmioApiDocBundle/issues/1857
* @dataProvider provideConstraintsWithGroups
*/
public function testReaderWithValidationGroupsEnabledDoesNotReadAnnotationsWithoutDefaultGroupIfNoGroupsArePassed($entity)
{
$schema = new OA\Schema([]);
$schema->merge([
new OA\Property(['property' => 'property1']),
]);
$reader = $this->createConstraintReaderWithValidationGroupsEnabled();
$reader->setSchema($schema);
// no serialization groups passed here
$reader->updateProperty(
new \ReflectionProperty($entity, 'property1'),
$schema->properties[0]
);
$this->assertSame(['property1'], $schema->required, 'should have read constraint in default group');
$this->assertSame(Generator::UNDEFINED, $schema->properties[0]->minimum, 'should not have read constraint in other group');
}
/**
* @group https://github.com/nelmio/NelmioApiDocBundle/issues/1857
* @dataProvider provideConstraintsWithGroups
*/
public function testReaderWithValidationGroupsEnabledReadsOnlyConstraintsWithGroupsProvided($entity)
{
$schema = new OA\Schema([]);
$schema->merge([
new OA\Property(['property' => 'property1']),
]);
$reader = $this->createConstraintReaderWithValidationGroupsEnabled();
$reader->setSchema($schema);
// no serialization groups passed here
$reader->updateProperty(
new \ReflectionProperty($entity, 'property1'),
$schema->properties[0],
['other']
);
$this->assertSame(Generator::UNDEFINED, $schema->required, 'should not have read constraint in default group');
$this->assertSame(1, $schema->properties[0]->minimum, 'should have read constraint in other group');
}
/**
* @group https://github.com/nelmio/NelmioApiDocBundle/issues/1857
* @dataProvider provideConstraintsWithGroups
*/
public function testReaderWithValidationGroupsEnabledCanReadFromMultipleValidationGroups($entity)
{
$schema = new OA\Schema([]);
$schema->merge([
new OA\Property(['property' => 'property1']),
]);
$reader = $this->createConstraintReaderWithValidationGroupsEnabled();
$reader->setSchema($schema);
// no serialization groups passed here
$reader->updateProperty(
new \ReflectionProperty($entity, 'property1'),
$schema->properties[0],
['other', Constraint::DEFAULT_GROUP]
);
$this->assertSame(['property1'], $schema->required, 'should have read constraint in default group');
$this->assertSame(1, $schema->properties[0]->minimum, 'should have read constraint in other group');
}
public function provideConstraintsWithGroups(): iterable
{
yield 'Annotations' => [new class() {
/**
* @Assert\NotBlank()
* @Assert\Range(min=1, groups={"other"})
*/
private $property1;
}];
if (\PHP_VERSION_ID >= 80000) {
yield 'Attributes' => [new class() {
#[Assert\NotBlank()]
#[Assert\Range(min: 1, groups: ['other'])]
private $property1;
}];
}
}
private function createConstraintReaderWithValidationGroupsEnabled(): SymfonyConstraintAnnotationReader
{
return new SymfonyConstraintAnnotationReader(
new AnnotationReader(),
true
);
}
} }