From d8626c273522701a4c9270010d67104ad80a9f30 Mon Sep 17 00:00:00 2001 From: Christopher Davis <cdavis9999@gmail.com> Date: Mon, 1 Feb 2021 08:37:20 -0600 Subject: [PATCH 1/4] Introduce a Trait to Build OpenAPI Discriminators See https://swagger.io/docs/specification/data-models/inheritance-and-polymorphism/ This is the adapter layer that will be included in the various model describers. The creation of the discriminator and the `oneOf` values is a little finicky and I wanted it to be tested and centralized. --- .../ApplyOpenApiDiscriminatorTrait.php | 60 +++++++++++++ .../ApplyOpenApiDiscriminatorTraitTest.php | 88 +++++++++++++++++++ 2 files changed, 148 insertions(+) create mode 100644 ModelDescriber/ApplyOpenApiDiscriminatorTrait.php create mode 100644 Tests/ModelDescriber/ApplyOpenApiDiscriminatorTraitTest.php diff --git a/ModelDescriber/ApplyOpenApiDiscriminatorTrait.php b/ModelDescriber/ApplyOpenApiDiscriminatorTrait.php new file mode 100644 index 0000000..dd551fd --- /dev/null +++ b/ModelDescriber/ApplyOpenApiDiscriminatorTrait.php @@ -0,0 +1,60 @@ +<?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\ModelDescriber; + +use Nelmio\ApiDocBundle\Model\Model; +use Nelmio\ApiDocBundle\Model\ModelRegistry; +use OpenApi\Annotations as OA; +use Symfony\Component\PropertyInfo\Type; + +/** + * Contains helper methods that add `discriminator` and `oneOf` values to + * Open API schemas to support poly morphism. + * + * @see https://swagger.io/docs/specification/data-models/inheritance-and-polymorphism/ + * @internal + */ +trait ApplyOpenApiDiscriminatorTrait +{ + /** + * @param Model $model the model that's being described, This is used to pass groups and config + * down to the children models in `oneOf` + * @param OA\Schema $schema The Open API schema to which `oneOf` and `discriminator` properties + * will be added + * @param string $discriminatorProperty The property that determine which model will be unsierailized + * @param array<string, string> $typeMap the map of $discriminatorProperty values to their + * types + */ + protected function applyOpenApiDiscriminator( + Model $model, + OA\Schema $schema, + ModelRegistry $modelRegistry, + string $discriminatorProperty, + array $typeMap + ) : void { + $schema->oneOf = []; + $schema->discriminator = new OA\Discriminator([]); + $schema->discriminator->propertyName = $discriminatorProperty; + $schema->discriminator->mapping = []; + foreach ($typeMap as $propertyValue => $className) { + $oneOfSchema = new OA\Schema([]); + $oneOfSchema->ref = $modelRegistry->register(new Model( + new Type(Type::BUILTIN_TYPE_OBJECT, false, $className), + $model->getGroups(), + $model->getOptions() + )); + $schema->oneOf[] = $oneOfSchema; + $schema->discriminator->mapping[$propertyValue] = clone $oneOfSchema; + + } + } +} diff --git a/Tests/ModelDescriber/ApplyOpenApiDiscriminatorTraitTest.php b/Tests/ModelDescriber/ApplyOpenApiDiscriminatorTraitTest.php new file mode 100644 index 0000000..0644a8b --- /dev/null +++ b/Tests/ModelDescriber/ApplyOpenApiDiscriminatorTraitTest.php @@ -0,0 +1,88 @@ +<?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\ModelDescriber; + +use Doctrine\Common\Annotations\AnnotationReader; +use Nelmio\ApiDocBundle\Model\Model; +use Nelmio\ApiDocBundle\Model\ModelRegistry; +use Nelmio\ApiDocBundle\ModelDescriber\ApplyOpenApiDiscriminatorTrait; +use OpenApi\Annotations as OA; +use Symfony\Component\PropertyInfo\Type; +use PHPUnit\Framework\TestCase; + +class ApplyOpenApiDiscriminatorTraitTest extends TestCase +{ + use ApplyOpenApiDiscriminatorTrait; + + const GROUPS = ['test']; + const OPTIONS = ['test' => 123]; + + private $schema; + + private $model; + + public function testApplyAddsDiscriminatorProperty() + { + $this->applyOpenApiDiscriminator($this->model, $this->schema, $this->modelRegistry, 'type', [ + 'one' => 'FirstType', + 'two' => 'SecondType', + ]); + + $this->assertInstanceOf(OA\Discriminator::class, $this->schema->discriminator); + $this->assertSame('type', $this->schema->discriminator->propertyName); + $this->assertArrayHasKey('one', $this->schema->discriminator->mapping); + $this->assertSame( + $this->modelRegistry->register($this->createModel('FirstType')), + $this->schema->discriminator->mapping['one']->ref + ); + $this->assertArrayHasKey('two', $this->schema->discriminator->mapping); + $this->assertSame( + $this->modelRegistry->register($this->createModel('SecondType')), + $this->schema->discriminator->mapping['two']->ref + ); + } + + public function testApplyAddsOneOfFieldToSchema() + { + $this->applyOpenApiDiscriminator($this->model, $this->schema, $this->modelRegistry, 'type', [ + 'one' => 'FirstType', + 'two' => 'SecondType', + ]); + + $this->assertNotSame(OA\UNDEFINED, $this->schema->oneOf); + $this->assertCount(2, $this->schema->oneOf); + $this->assertSame( + $this->modelRegistry->register($this->createModel('FirstType')), + $this->schema->oneOf[0]->ref + ); + $this->assertSame( + $this->modelRegistry->register($this->createModel('SecondType')), + $this->schema->oneOf[1]->ref + ); + } + + protected function setUp() : void + { + $this->schema = new OA\Schema([]); + $this->model = $this->createModel(__CLASS__); + $this->modelRegistry = new ModelRegistry([], new OA\OpenApi([])); + } + + private function createModel(string $className) : Model + { + return new Model( + new Type(Type::BUILTIN_TYPE_OBJECT, false, $className), + self::GROUPS, + self::OPTIONS + ); + } +} From 9299c0e52ea6a45bcd94961dd2170234d142ea5f Mon Sep 17 00:00:00 2001 From: Christopher Davis <cdavis9999@gmail.com> Date: Mon, 1 Feb 2021 08:56:31 -0600 Subject: [PATCH 2/4] Support OpenAPI Polymorphism in ObjectModelDescriber This is the default "symfony support" class, so seems like the right place. --- ModelDescriber/ObjectModelDescriber.php | 13 +++++++++ Tests/Functional/Controller/ApiController.php | 10 +++++++ .../Entity/SymfonyDiscriminator.php | 28 +++++++++++++++++++ .../Entity/SymfonyDiscriminatorOne.php | 20 +++++++++++++ .../Entity/SymfonyDiscriminatorTwo.php | 20 +++++++++++++ Tests/Functional/FunctionalTest.php | 20 +++++++++++++ 6 files changed, 111 insertions(+) create mode 100644 Tests/Functional/Entity/SymfonyDiscriminator.php create mode 100644 Tests/Functional/Entity/SymfonyDiscriminatorOne.php create mode 100644 Tests/Functional/Entity/SymfonyDiscriminatorTwo.php diff --git a/ModelDescriber/ObjectModelDescriber.php b/ModelDescriber/ObjectModelDescriber.php index fc86439..893ae96 100644 --- a/ModelDescriber/ObjectModelDescriber.php +++ b/ModelDescriber/ObjectModelDescriber.php @@ -22,11 +22,13 @@ use Nelmio\ApiDocBundle\PropertyDescriber\PropertyDescriberInterface; use OpenApi\Annotations as OA; use Symfony\Component\PropertyInfo\PropertyInfoExtractorInterface; use Symfony\Component\PropertyInfo\Type; +use Symfony\Component\Serializer\Annotation\DiscriminatorMap; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; class ObjectModelDescriber implements ModelDescriberInterface, ModelRegistryAwareInterface { use ModelRegistryAwareTrait; + use ApplyOpenApiDiscriminatorTrait; /** @var PropertyInfoExtractorInterface */ private $propertyInfo; @@ -71,6 +73,17 @@ class ObjectModelDescriber implements ModelDescriberInterface, ModelRegistryAwar $annotationsReader = new AnnotationsReader($this->doctrineReader, $this->modelRegistry, $this->mediaTypes); $annotationsReader->updateDefinition($reflClass, $schema); + $discriminatorMap = $this->doctrineReader->getClassAnnotation($reflClass, DiscriminatorMap::class); + if ($discriminatorMap && $schema->discriminator === OA\UNDEFINED) { + $this->applyOpenApiDiscriminator( + $model, + $schema, + $this->modelRegistry, + $discriminatorMap->getTypeProperty(), + $discriminatorMap->getMapping() + ); + } + $propertyInfoProperties = $this->propertyInfo->getProperties($class, $context); if (null === $propertyInfoProperties) { diff --git a/Tests/Functional/Controller/ApiController.php b/Tests/Functional/Controller/ApiController.php index 4988efd..554b1eb 100644 --- a/Tests/Functional/Controller/ApiController.php +++ b/Tests/Functional/Controller/ApiController.php @@ -18,6 +18,7 @@ use Nelmio\ApiDocBundle\Annotation\Security; use Nelmio\ApiDocBundle\Tests\Functional\Entity\Article; use Nelmio\ApiDocBundle\Tests\Functional\Entity\CompoundEntity; use Nelmio\ApiDocBundle\Tests\Functional\Entity\SymfonyConstraints; +use Nelmio\ApiDocBundle\Tests\Functional\Entity\SymfonyDiscriminator; use Nelmio\ApiDocBundle\Tests\Functional\Entity\User; use Nelmio\ApiDocBundle\Tests\Functional\Form\DummyType; use Nelmio\ApiDocBundle\Tests\Functional\Form\UserType; @@ -221,4 +222,13 @@ class ApiController public function compoundEntityAction() { } + + /** + * @Route("/discriminator-mapping", methods={"GET", "POST"}) + * + * @OA\Response(response=200, description="Worked well!", @Model(type=SymfonyDiscriminator::class)) + */ + public function discriminatorMappingAction() + { + } } diff --git a/Tests/Functional/Entity/SymfonyDiscriminator.php b/Tests/Functional/Entity/SymfonyDiscriminator.php new file mode 100644 index 0000000..cd4e562 --- /dev/null +++ b/Tests/Functional/Entity/SymfonyDiscriminator.php @@ -0,0 +1,28 @@ +<?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\DiscriminatorMap; + +/** + * @DiscriminatorMap(typeProperty="type", mapping={ + * "one": SymfonyDiscriminatorOne::class, + * "two": SymfonyDiscriminatorTwo::class, + * }) + */ +abstract class SymfonyDiscriminator +{ + /** + * @var string + */ + public $type; +} diff --git a/Tests/Functional/Entity/SymfonyDiscriminatorOne.php b/Tests/Functional/Entity/SymfonyDiscriminatorOne.php new file mode 100644 index 0000000..6b969ef --- /dev/null +++ b/Tests/Functional/Entity/SymfonyDiscriminatorOne.php @@ -0,0 +1,20 @@ +<?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; + +class SymfonyDiscriminatorOne extends SymfonyDiscriminator +{ + /** + * @var string + */ + public $one; +} diff --git a/Tests/Functional/Entity/SymfonyDiscriminatorTwo.php b/Tests/Functional/Entity/SymfonyDiscriminatorTwo.php new file mode 100644 index 0000000..9e6b9eb --- /dev/null +++ b/Tests/Functional/Entity/SymfonyDiscriminatorTwo.php @@ -0,0 +1,20 @@ +<?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; + +class SymfonyDiscriminatorTwo extends SymfonyDiscriminator +{ + /** + * @var string + */ + public $two; +} diff --git a/Tests/Functional/FunctionalTest.php b/Tests/Functional/FunctionalTest.php index 914a032..b7ba209 100644 --- a/Tests/Functional/FunctionalTest.php +++ b/Tests/Functional/FunctionalTest.php @@ -498,4 +498,24 @@ class FunctionalTest extends WebTestCase $this->assertNotHasProperty('protectedField', $model); $this->assertNotHasProperty('protected', $model); } + + public function testModelsWithDiscriminatorMapAreLoadedWithOpenApiPolymorphism() + { + $model = $this->getModel('SymfonyDiscriminator'); + + $this->assertInstanceOf(OA\Discriminator::class, $model->discriminator); + $this->assertSame('type', $model->discriminator->propertyName); + $this->assertCount(2, $model->discriminator->mapping); + $this->assertArrayHasKey('one', $model->discriminator->mapping); + $this->assertArrayHasKey('two', $model->discriminator->mapping); + $this->assertNotSame(OA\UNDEFINED, $model->oneOf); + $this->assertCount(2, $model->oneOf); + } + + public function testDiscriminatorMapLoadsChildrenModels() + { + // get model does its own assertions + $this->getModel('SymfonyDiscriminatorOne'); + $this->getModel('SymfonyDiscriminatorTwo'); + } } From ac7e29da2112a7110f0b4166b442964a9c72c6e6 Mon Sep 17 00:00:00 2001 From: Christopher Davis <cdavis9999@gmail.com> Date: Mon, 1 Feb 2021 09:50:15 -0600 Subject: [PATCH 3/4] Fix CS --- .../ApplyOpenApiDiscriminatorTrait.php | 18 +++++++++--------- ModelDescriber/ObjectModelDescriber.php | 2 +- .../ApplyOpenApiDiscriminatorTraitTest.php | 7 +++---- 3 files changed, 13 insertions(+), 14 deletions(-) diff --git a/ModelDescriber/ApplyOpenApiDiscriminatorTrait.php b/ModelDescriber/ApplyOpenApiDiscriminatorTrait.php index dd551fd..d3e5fe9 100644 --- a/ModelDescriber/ApplyOpenApiDiscriminatorTrait.php +++ b/ModelDescriber/ApplyOpenApiDiscriminatorTrait.php @@ -21,18 +21,19 @@ use Symfony\Component\PropertyInfo\Type; * Open API schemas to support poly morphism. * * @see https://swagger.io/docs/specification/data-models/inheritance-and-polymorphism/ + * * @internal */ trait ApplyOpenApiDiscriminatorTrait { /** - * @param Model $model the model that's being described, This is used to pass groups and config - * down to the children models in `oneOf` - * @param OA\Schema $schema The Open API schema to which `oneOf` and `discriminator` properties - * will be added - * @param string $discriminatorProperty The property that determine which model will be unsierailized - * @param array<string, string> $typeMap the map of $discriminatorProperty values to their - * types + * @param Model $model the model that's being described, This is used to pass groups and config + * down to the children models in `oneOf` + * @param OA\Schema $schema The Open API schema to which `oneOf` and `discriminator` properties + * will be added + * @param string $discriminatorProperty The property that determine which model will be unsierailized + * @param array<string, string> $typeMap the map of $discriminatorProperty values to their + * types */ protected function applyOpenApiDiscriminator( Model $model, @@ -40,7 +41,7 @@ trait ApplyOpenApiDiscriminatorTrait ModelRegistry $modelRegistry, string $discriminatorProperty, array $typeMap - ) : void { + ): void { $schema->oneOf = []; $schema->discriminator = new OA\Discriminator([]); $schema->discriminator->propertyName = $discriminatorProperty; @@ -54,7 +55,6 @@ trait ApplyOpenApiDiscriminatorTrait )); $schema->oneOf[] = $oneOfSchema; $schema->discriminator->mapping[$propertyValue] = clone $oneOfSchema; - } } } diff --git a/ModelDescriber/ObjectModelDescriber.php b/ModelDescriber/ObjectModelDescriber.php index 893ae96..5260da8 100644 --- a/ModelDescriber/ObjectModelDescriber.php +++ b/ModelDescriber/ObjectModelDescriber.php @@ -74,7 +74,7 @@ class ObjectModelDescriber implements ModelDescriberInterface, ModelRegistryAwar $annotationsReader->updateDefinition($reflClass, $schema); $discriminatorMap = $this->doctrineReader->getClassAnnotation($reflClass, DiscriminatorMap::class); - if ($discriminatorMap && $schema->discriminator === OA\UNDEFINED) { + if ($discriminatorMap && OA\UNDEFINED === $schema->discriminator) { $this->applyOpenApiDiscriminator( $model, $schema, diff --git a/Tests/ModelDescriber/ApplyOpenApiDiscriminatorTraitTest.php b/Tests/ModelDescriber/ApplyOpenApiDiscriminatorTraitTest.php index 0644a8b..9ad9c8a 100644 --- a/Tests/ModelDescriber/ApplyOpenApiDiscriminatorTraitTest.php +++ b/Tests/ModelDescriber/ApplyOpenApiDiscriminatorTraitTest.php @@ -11,13 +11,12 @@ namespace Nelmio\ApiDocBundle\Tests\ModelDescriber; -use Doctrine\Common\Annotations\AnnotationReader; use Nelmio\ApiDocBundle\Model\Model; use Nelmio\ApiDocBundle\Model\ModelRegistry; use Nelmio\ApiDocBundle\ModelDescriber\ApplyOpenApiDiscriminatorTrait; use OpenApi\Annotations as OA; -use Symfony\Component\PropertyInfo\Type; use PHPUnit\Framework\TestCase; +use Symfony\Component\PropertyInfo\Type; class ApplyOpenApiDiscriminatorTraitTest extends TestCase { @@ -70,14 +69,14 @@ class ApplyOpenApiDiscriminatorTraitTest extends TestCase ); } - protected function setUp() : void + protected function setUp(): void { $this->schema = new OA\Schema([]); $this->model = $this->createModel(__CLASS__); $this->modelRegistry = new ModelRegistry([], new OA\OpenApi([])); } - private function createModel(string $className) : Model + private function createModel(string $className): Model { return new Model( new Type(Type::BUILTIN_TYPE_OBJECT, false, $className), From 87004fc4286e4ed8d46073632f7e1b84b4939cce Mon Sep 17 00:00:00 2001 From: Christopher Davis <cdavis9999@gmail.com> Date: Mon, 8 Feb 2021 15:39:14 -0600 Subject: [PATCH 4/4] Don't Use `ref` in Discriminator `mapping` Instead just include the schema ref directly per the documentation. --- ModelDescriber/ApplyOpenApiDiscriminatorTrait.php | 2 +- Tests/ModelDescriber/ApplyOpenApiDiscriminatorTraitTest.php | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/ModelDescriber/ApplyOpenApiDiscriminatorTrait.php b/ModelDescriber/ApplyOpenApiDiscriminatorTrait.php index d3e5fe9..3c9446d 100644 --- a/ModelDescriber/ApplyOpenApiDiscriminatorTrait.php +++ b/ModelDescriber/ApplyOpenApiDiscriminatorTrait.php @@ -54,7 +54,7 @@ trait ApplyOpenApiDiscriminatorTrait $model->getOptions() )); $schema->oneOf[] = $oneOfSchema; - $schema->discriminator->mapping[$propertyValue] = clone $oneOfSchema; + $schema->discriminator->mapping[$propertyValue] = $oneOfSchema->ref; } } } diff --git a/Tests/ModelDescriber/ApplyOpenApiDiscriminatorTraitTest.php b/Tests/ModelDescriber/ApplyOpenApiDiscriminatorTraitTest.php index 9ad9c8a..51df5e4 100644 --- a/Tests/ModelDescriber/ApplyOpenApiDiscriminatorTraitTest.php +++ b/Tests/ModelDescriber/ApplyOpenApiDiscriminatorTraitTest.php @@ -41,12 +41,12 @@ class ApplyOpenApiDiscriminatorTraitTest extends TestCase $this->assertArrayHasKey('one', $this->schema->discriminator->mapping); $this->assertSame( $this->modelRegistry->register($this->createModel('FirstType')), - $this->schema->discriminator->mapping['one']->ref + $this->schema->discriminator->mapping['one'] ); $this->assertArrayHasKey('two', $this->schema->discriminator->mapping); $this->assertSame( $this->modelRegistry->register($this->createModel('SecondType')), - $this->schema->discriminator->mapping['two']->ref + $this->schema->discriminator->mapping['two'] ); }