From b8479d02e1abff2d7f3050f20d38da3480b086de Mon Sep 17 00:00:00 2001 From: Guilhem Niot Date: Sun, 25 Jun 2017 15:40:07 +0200 Subject: [PATCH] Add JMS serializer support --- DependencyInjection/Configuration.php | 6 + DependencyInjection/NelmioApiDocExtension.php | 19 ++- ModelDescriber/FormModelDescriber.php | 6 +- ModelDescriber/JMSModelDescriber.php | 128 ++++++++++++++++++ README.md | 29 +++- Tests/Functional/Controller/JMSController.php | 32 +++++ Tests/Functional/Entity/JMSUser.php | 77 +++++++++++ Tests/Functional/JMSFunctionalTest.php | 46 +++++++ Tests/Functional/TestKernel.php | 51 ++++++- Tests/Functional/WebTestCase.php | 7 +- composer.json | 3 +- 11 files changed, 388 insertions(+), 16 deletions(-) create mode 100644 ModelDescriber/JMSModelDescriber.php create mode 100644 Tests/Functional/Controller/JMSController.php create mode 100644 Tests/Functional/Entity/JMSUser.php create mode 100644 Tests/Functional/JMSFunctionalTest.php diff --git a/DependencyInjection/Configuration.php b/DependencyInjection/Configuration.php index 6dfa21d..b00a713 100644 --- a/DependencyInjection/Configuration.php +++ b/DependencyInjection/Configuration.php @@ -37,6 +37,12 @@ final class Configuration implements ConfigurationInterface ->end() ->end() ->end() + ->arrayNode('models') + ->addDefaultsIfNotSet() + ->children() + ->booleanNode('use_jms')->defaultFalse()->end() + ->end() + ->end() ->end(); return $treeBuilder; diff --git a/DependencyInjection/NelmioApiDocExtension.php b/DependencyInjection/NelmioApiDocExtension.php index bb98a58..1ae62a6 100644 --- a/DependencyInjection/NelmioApiDocExtension.php +++ b/DependencyInjection/NelmioApiDocExtension.php @@ -13,6 +13,7 @@ namespace Nelmio\ApiDocBundle\DependencyInjection; use FOS\RestBundle\Controller\Annotations\ParamInterface; use Nelmio\ApiDocBundle\ModelDescriber\FormModelDescriber; +use Nelmio\ApiDocBundle\ModelDescriber\JMSModelDescriber; use Nelmio\ApiDocBundle\Routing\FilteredRouteCollectionBuilder; use phpDocumentor\Reflection\DocBlockFactory; use Symfony\Component\Config\FileLocator; @@ -33,6 +34,12 @@ final class NelmioApiDocExtension extends Extension implements PrependExtensionI public function prepend(ContainerBuilder $container) { $container->prependExtensionConfig('framework', ['property_info' => ['enabled' => true]]); + + // JMS Serializer support + $bundles = $container->getParameter('kernel.bundles'); + if (isset($bundles['JMSSerializerBundle'])) { + $container->prependExtensionConfig('nelmio_api_doc', ['models' => ['use_jms' => true]]); + } } /** @@ -49,7 +56,7 @@ final class NelmioApiDocExtension extends Extension implements PrependExtensionI $container->register('nelmio_api_doc.model_describers.form', FormModelDescriber::class) ->setPublic(false) ->addArgument(new Reference('form.factory')) - ->addTag('nelmio_api_doc.model_describer', ['priority' => 10]); + ->addTag('nelmio_api_doc.model_describer', ['priority' => 100]); } // Filter routes @@ -79,12 +86,20 @@ final class NelmioApiDocExtension extends Extension implements PrependExtensionI $loader->load('fos_rest.xml'); } - $bundles = $container->getParameter('kernel.bundles'); // ApiPlatform support + $bundles = $container->getParameter('kernel.bundles'); if (isset($bundles['ApiPlatformBundle']) && class_exists('ApiPlatform\Core\Documentation\Documentation')) { $loader->load('api_platform.xml'); } + // JMS metadata support + if ($config['models']['use_jms']) { + $container->register('nelmio_api_doc.model_describers.jms', JMSModelDescriber::class) + ->setPublic(false) + ->setArguments([new Reference('jms_serializer.metadata_factory'), new Reference('jms_serializer.naming_strategy')]) + ->addTag('nelmio_api_doc.model_describer', ['priority' => 50]); + } + // Import the base configuration $container->getDefinition('nelmio_api_doc.describers.config')->replaceArgument(0, $config['documentation']); } diff --git a/ModelDescriber/FormModelDescriber.php b/ModelDescriber/FormModelDescriber.php index 1047fd7..9a50111 100644 --- a/ModelDescriber/FormModelDescriber.php +++ b/ModelDescriber/FormModelDescriber.php @@ -12,8 +12,6 @@ namespace Nelmio\ApiDocBundle\ModelDescriber; use EXSyst\Component\Swagger\Schema; -use Nelmio\ApiDocBundle\Describer\ModelRegistryAwareInterface; -use Nelmio\ApiDocBundle\Describer\ModelRegistryAwareTrait; use Nelmio\ApiDocBundle\Model\Model; use Symfony\Component\Form\FormFactoryInterface; use Symfony\Component\Form\FormTypeInterface; @@ -21,10 +19,8 @@ use Symfony\Component\Form\FormTypeInterface; /** * @internal */ -final class FormModelDescriber implements ModelDescriberInterface, ModelRegistryAwareInterface +final class FormModelDescriber implements ModelDescriberInterface { - use ModelRegistryAwareTrait; - private $formFactory; public function __construct(FormFactoryInterface $formFactory = null) diff --git a/ModelDescriber/JMSModelDescriber.php b/ModelDescriber/JMSModelDescriber.php new file mode 100644 index 0000000..16757ea --- /dev/null +++ b/ModelDescriber/JMSModelDescriber.php @@ -0,0 +1,128 @@ +factory = $factory; + $this->namingStrategy = $namingStrategy; + } + + /** + * {@inheritdoc} + */ + public function describe(Model $model, Schema $schema) + { + $className = $model->getType()->getClassName(); + $metadata = $this->factory->getMetadataForClass($className); + if (null === $metadata) { + throw new \InvalidArgumentException(sprintf('No metadata found for class %s.', $className)); + } + + $groupsExclusion = null !== $model->getGroups() ? new GroupsExclusionStrategy($model->getGroups()) : null; + + $schema->setType('object'); + $properties = $schema->getProperties(); + foreach ($metadata->propertyMetadata as $item) { + if (null === $item->type) { + continue; + } + + // filter groups + if (null !== $groupsExclusion && $groupsExclusion->shouldSkipProperty($item, SerializationContext::create())) { + continue; + } + + $name = $this->namingStrategy->translateName($item); + $property = $properties->get($name); + + if ($type = $this->getNestedTypeInArray($item)) { + $property->setType('array'); + $property = $property->getItems(); + } else { + $type = $item->type['name']; + } + + if (in_array($type, array('boolean', 'integer', 'string', ' float', 'array'))) { + $property->setType($type); + } elseif ('double' === $type) { + $property->setType('float'); + } elseif ('DateTime' === $type || 'DateTimeImmutable' === $type) { + $property->setType('string'); + $property->setFormat('date-time'); + } else { + // we can use property type also for custom handlers, then we don't have here real class name + if (!class_exists($type)) { + continue; + } + + $property->setRef( + $this->modelRegistry->register(new Model(new Type(Type::BUILTIN_TYPE_OBJECT, false, $type), $model->getGroups())) + ); + } + } + } + + /** + * {@inheritdoc} + */ + public function supports(Model $model): bool + { + $className = $model->getType()->getClassName(); + try { + if ($this->factory->getMetadataForClass($className)) { + return true; + } + } catch (\ReflectionException $e) { + } + + return false; + } + + private function getNestedTypeInArray(PropertyMetadata $item) + { + if ('array' !== $item->type['name'] && 'ArrayCollection' !== $item->type['name']) { + return; + } + + // array + if (isset($item->type['params'][1]['name'])) { + return $item->type['params'][1]['name']; + } + + // array + if (isset($item->type['params'][0]['name'])) { + return $item->type['params'][0]['name']; + } + } +} diff --git a/README.md b/README.md index c6d48b0..770375a 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,33 @@ class UserController } ``` +## Use models + +As shown in the example above, the bundle provides the ``@Model`` annotation. +When you use it, the bundle will deduce your model properties. + +### If you're not using the JMS Serializer + +The [Symfony PropertyInfo component](https://symfony.com/doc/current/components/property_info.html) +is used to describe your models. It supports doctrine annotations, type hints, +and even PHP doc blocks as long as you required the +``phpdocumentor/reflection-docblock`` library. It does also support +serialization groups when using the Symfony serializer. + +### If you're using the JMS Serializer + +The metadata of the JMS serializer are used by default to describe your +models. Note that PHP doc blocks aren't supported in this case. + +In case you prefer using the [Symfony PropertyInfo component](https://symfony.com/doc/current/components/property_info.html) (you +won't be able to use JMS serialization groups), you can disable JMS serializer +support in your config: + +```yml +nelmio_api_doc: + models: { use_jms: false } +``` + ## What's supported? This bundle supports _Symfony_ route requirements, PHP annotations, @@ -142,7 +169,7 @@ This bundle supports _Symfony_ route requirements, PHP annotations, [_FOSRestBundle_](https://github.com/FriendsOfSymfony/FOSRestBundle) annotations and apps using [_Api-Platform_](https://github.com/api-platform/api-platform). -It supports models through the ``@Model`` annotation. +For models, it supports the Symfony serializer and the JMS serializer. ## Contributing diff --git a/Tests/Functional/Controller/JMSController.php b/Tests/Functional/Controller/JMSController.php new file mode 100644 index 0000000..a2452bc --- /dev/null +++ b/Tests/Functional/Controller/JMSController.php @@ -0,0 +1,32 @@ +") + * @Serializer\Accessor(getter="getRoles", setter="setRoles") + * @Serializer\Expose + */ + private $roles; + + /** + * @Serializer\Type("string") + */ + private $password; + + /** + * Ignored as the JMS serializer can't detect its type. + * + * @Serializer\Expose + */ + private $createdAt; + + /** + * @Serializer\Type("array") + * @Serializer\Expose + */ + private $friends; + + /** + * @Serializer\Type(User::class) + * @Serializer\Expose + */ + private $bestFriend; + + public function setRoles($roles) + { + } + + public function getRoles() + { + } + + public function setDummy(Dummy $dummy) + { + } +} diff --git a/Tests/Functional/JMSFunctionalTest.php b/Tests/Functional/JMSFunctionalTest.php new file mode 100644 index 0000000..b11507a --- /dev/null +++ b/Tests/Functional/JMSFunctionalTest.php @@ -0,0 +1,46 @@ +assertEquals([ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'integer'], + 'email' => ['type' => 'string'], + 'roles' => [ + 'type' => 'array', + 'items' => ['type' => 'string'], + ], + 'friends' => [ + 'type' => 'array', + 'items' => [ + '$ref' => '#/definitions/User', + ], + ], + 'best_friend' => [ + '$ref' => '#/definitions/User', + ], + ], + ], $this->getModel('JMSUser')->toArray()); + } + + protected static function createKernel(array $options = array()) + { + return new TestKernel(true); + } +} diff --git a/Tests/Functional/TestKernel.php b/Tests/Functional/TestKernel.php index 46d0fe2..cfce559 100644 --- a/Tests/Functional/TestKernel.php +++ b/Tests/Functional/TestKernel.php @@ -13,6 +13,7 @@ namespace Nelmio\ApiDocBundle\Tests\Functional; use ApiPlatform\Core\Bridge\Symfony\Bundle\ApiPlatformBundle; use FOS\RestBundle\FOSRestBundle; +use JMS\SerializerBundle\JMSSerializerBundle; use Nelmio\ApiDocBundle\NelmioApiDocBundle; use Sensio\Bundle\FrameworkExtraBundle\SensioFrameworkExtraBundle; use Symfony\Bundle\FrameworkBundle\FrameworkBundle; @@ -27,12 +28,21 @@ class TestKernel extends Kernel { use MicroKernelTrait; + private $useJMS; + + public function __construct(bool $useJMS = false) + { + parent::__construct('test'.(int) $useJMS, true); + + $this->useJMS = $useJMS; + } + /** * {@inheritdoc} */ public function registerBundles() { - return [ + $bundles = [ new FrameworkBundle(), new TwigBundle(), new SensioFrameworkExtraBundle(), @@ -41,6 +51,12 @@ class TestKernel extends Kernel new FOSRestBundle(), new TestBundle(), ]; + + if ($this->useJMS) { + $bundles[] = new JMSSerializerBundle(); + } + + return $bundles; } /** @@ -48,11 +64,16 @@ class TestKernel extends Kernel */ protected function configureRoutes(RouteCollectionBuilder $routes) { - $routes->import(__DIR__.'/Controller/', '/', 'annotation'); + $routes->import(__DIR__.'/Controller/ApiController.php', '/', 'annotation'); + $routes->import(__DIR__.'/Controller/UndocumentedController.php', '/', 'annotation'); $routes->import('', '/api', 'api_platform'); $routes->import('@NelmioApiDocBundle/Resources/config/routing/swaggerui.xml', '/docs'); $routes->add('/docs.json', 'nelmio_api_doc.controller.swagger'); + + if ($this->useJMS) { + $routes->import(__DIR__.'/Controller/JMSController.php', '/', 'annotation'); + } } /** @@ -94,4 +115,30 @@ class TestKernel extends Kernel ], ]); } + + /** + * {@inheritdoc} + */ + public function getCacheDir() + { + return parent::getCacheDir().'/'.(int) $this->useJMS; + } + + /** + * {@inheritdoc} + */ + public function getLogDir() + { + return parent::getLogDir().'/'.(int) $this->useJMS; + } + + public function serialize() + { + return serialize($this->useJMS); + } + + public function unserialize($str) + { + $this->__construct(unserialize($str)); + } } diff --git a/Tests/Functional/WebTestCase.php b/Tests/Functional/WebTestCase.php index d14b391..cfa70d1 100644 --- a/Tests/Functional/WebTestCase.php +++ b/Tests/Functional/WebTestCase.php @@ -17,12 +17,9 @@ use Symfony\Bundle\FrameworkBundle\Test\WebTestCase as BaseWebTestCase; class WebTestCase extends BaseWebTestCase { - /** - * {@inheritdoc} - */ - protected static function getKernelClass() + protected static function createKernel(array $options = array()) { - return TestKernel::class; + return new TestKernel(); } protected function getSwaggerDefinition() diff --git a/composer.json b/composer.json index 8ce9f8a..d826f6a 100644 --- a/composer.json +++ b/composer.json @@ -39,7 +39,8 @@ "phpdocumentor/reflection-docblock": "^3.1", "api-platform/core": "^2.0.3", - "friendsofsymfony/rest-bundle": "^2.0" + "friendsofsymfony/rest-bundle": "^2.0", + "jms/serializer-bundle": "^1.0|^2.0" }, "suggest": { "phpdocumentor/reflection-docblock": "For parsing php docs.",