Add a Configuration Option to Enable Validation Groups

If this was turned on by default, that seems like a _large_ BC break as
folks entire OpenAPI doc could change underneath them.

The config option defaults to false and users can enable it if they
desire.
This commit is contained in:
Christopher Davis 2021-11-06 07:13:56 -05:00
parent cc962b72c8
commit 7357de9c16
14 changed files with 200 additions and 13 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)
@ -175,6 +176,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

@ -27,14 +27,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): void public function updateDefinition(\ReflectionClass $reflectionClass, OA\Schema $schema): void

View File

@ -37,9 +37,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) {
@ -51,6 +56,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)
@ -66,7 +72,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
);
$annotationsReader->updateDefinition(new \ReflectionClass($class), $schema); $annotationsReader->updateDefinition(new \ReflectionClass($class), $schema);
$form = $this->formFactory->create($class, null, $model->getOptions() ?? []); $form = $this->formFactory->create($class, null, $model->getOptions() ?? []);

View File

@ -49,16 +49,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
} }
$schema->type = 'object'; $schema->type = 'object';
$annotationsReader = new AnnotationsReader($this->doctrineReader, $this->modelRegistry, $this->mediaTypes); $annotationsReader = new AnnotationsReader(
$this->doctrineReader,
$this->modelRegistry,
$this->mediaTypes,
$this->useValidationGroups
);
$annotationsReader->updateDefinition(new \ReflectionClass($className), $schema); $annotationsReader->updateDefinition(new \ReflectionClass($className), $schema);
$isJmsV1 = null !== $this->namingStrategy; $isJmsV1 = null !== $this->namingStrategy;

View File

@ -40,19 +40,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)
@ -68,7 +72,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
);
$annotationsReader->updateDefinition($reflClass, $schema); $annotationsReader->updateDefinition($reflClass, $schema);
$discriminatorMap = $this->doctrineReader->getClassAnnotation($reflClass, DiscriminatorMap::class); $discriminatorMap = $this->doctrineReader->getClassAnnotation($reflClass, DiscriminatorMap::class);

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

@ -18,6 +18,7 @@ use Nelmio\ApiDocBundle\Annotation\Security;
use Nelmio\ApiDocBundle\Tests\Functional\Entity\Article; use Nelmio\ApiDocBundle\Tests\Functional\Entity\Article;
use Nelmio\ApiDocBundle\Tests\Functional\Entity\CompoundEntity; use Nelmio\ApiDocBundle\Tests\Functional\Entity\CompoundEntity;
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;
@ -179,6 +180,18 @@ class ApiController
{ {
} }
/**
* @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()
{
}
/** /**
* @OA\Response( * @OA\Response(
* response="200", * response="200",

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,36 @@
<?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 Nelmio\ApiDocBundle\Tests\ModelDescriber\Annotations\Fixture as CustomAssert;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Serializer\Annotation\Groups;
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

@ -364,8 +364,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

@ -19,6 +19,7 @@ use JMS\SerializerBundle\JMSSerializerBundle;
use Nelmio\ApiDocBundle\NelmioApiDocBundle; use Nelmio\ApiDocBundle\NelmioApiDocBundle;
use Nelmio\ApiDocBundle\Tests\Functional\Entity\BazingaUser; 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\SymfonyConstraintsWithValidationGroups;
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\ModelDescriber\VirtualTypeClassDoesNotExistsHandlerDefinedDescriber; use Nelmio\ApiDocBundle\Tests\Functional\ModelDescriber\VirtualTypeClassDoesNotExistsHandlerDefinedDescriber;
@ -32,12 +33,14 @@ use Symfony\Component\DependencyInjection\Definition;
use Symfony\Component\HttpKernel\Kernel; use Symfony\Component\HttpKernel\Kernel;
use Symfony\Component\Routing\RouteCollectionBuilder; use Symfony\Component\Routing\RouteCollectionBuilder;
use Symfony\Component\Serializer\Annotation\SerializedName; use Symfony\Component\Serializer\Annotation\SerializedName;
use Symfony\Component\Validator\Constraint;
class TestKernel extends Kernel 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;
@ -173,6 +176,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
[ [
@ -263,6 +267,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,77 @@
<?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;
class ValidationGroupsFunctionalTest extends WebTestCase
{
protected static function createKernel(array $options = [])
{
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)
);
}
}