From e0526286fae20cddacd59365e1aa673c4179591f Mon Sep 17 00:00:00 2001 From: Teoh Han Hui Date: Wed, 9 Mar 2016 13:36:24 +0800 Subject: [PATCH] Import AnnotationsProvider & Parser from NelmioApiDocBundle --- composer.json | 6 +- .../ApiPlatformProvider.php | 226 ++++++++++++++++++ .../NelmioApiDoc/Parser/ApiPlatformParser.php | 204 ++++++++++++++++ .../ApiPlatformExtension.php | 5 + .../DependencyInjection/Configuration.php | 1 + .../Resources/config/nelmio_api_doc.xml | 29 +++ .../DependencyInjection/ConfigurationTest.php | 1 + 7 files changed, 470 insertions(+), 2 deletions(-) create mode 100644 src/Bridge/NelmioApiDoc/Extractor/AnnotationsProvider/ApiPlatformProvider.php create mode 100644 src/Bridge/NelmioApiDoc/Parser/ApiPlatformParser.php create mode 100644 src/Bridge/Symfony/Bundle/Resources/config/nelmio_api_doc.xml diff --git a/composer.json b/composer.json index c2cbb358973..8cf53aaf7f1 100644 --- a/composer.json +++ b/composer.json @@ -42,10 +42,12 @@ "phpdocumentor/reflection-docblock": "~3.0", "doctrine/orm": "~2.2,>=2.2.3", "doctrine/doctrine-bundle": "dev-property_info", - "php-mock/php-mock-phpunit": "~1.1" + "php-mock/php-mock-phpunit": "~1.1", + "nelmio/api-doc-bundle": "^2.11.2" }, "suggest": { - "friendsofsymfony/user-bundle": "To use the FOSUserBundle bridge." + "friendsofsymfony/user-bundle": "To use the FOSUserBundle bridge.", + "nelmio/api-doc-bundle": "To have the api sandbox & documentation." }, "autoload": { "psr-4": { "ApiPlatform\\Core\\": "src/" } diff --git a/src/Bridge/NelmioApiDoc/Extractor/AnnotationsProvider/ApiPlatformProvider.php b/src/Bridge/NelmioApiDoc/Extractor/AnnotationsProvider/ApiPlatformProvider.php new file mode 100644 index 00000000000..ce619052a75 --- /dev/null +++ b/src/Bridge/NelmioApiDoc/Extractor/AnnotationsProvider/ApiPlatformProvider.php @@ -0,0 +1,226 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace ApiPlatform\Core\Bridge\NelmioApiDoc\Extractor\AnnotationsProvider; + +use ApiPlatform\Core\Api\FilterCollection; +use ApiPlatform\Core\Api\OperationMethodResolverInterface; +use ApiPlatform\Core\Bridge\NelmioApiDoc\ApiPlatformParser; +use ApiPlatform\Core\Hydra\ApiDocumentationBuilderInterface; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; +use Nelmio\ApiDocBundle\Annotation\ApiDoc; +use Nelmio\ApiDocBundle\Extractor\AnnotationsProviderInterface; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\Routing\Route; +use Symfony\Component\Routing\RouterInterface; + +/** + * Creates Nelmio ApiDoc annotations for the api platform. + * + * @author Kévin Dunglas + */ +final class ApiPlatformProvider implements AnnotationsProviderInterface +{ + private $resourceNameCollectionFactory; + private $apiDocumentationBuilder; + private $resourceMetadataFactory; + private $filters; + private $operationMethodResolver; + private $router; + + public function __construct(ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, ApiDocumentationBuilderInterface $apiDocumentationBuilder, ResourceMetadataFactoryInterface $resourceMetadataFactory, FilterCollection $filters, OperationMethodResolverInterface $operationMethodResolver, RouterInterface $router) + { + $this->resourceNameCollectionFactory = $resourceNameCollectionFactory; + $this->apiDocumentationBuilder = $apiDocumentationBuilder; + $this->resourceMetadataFactory = $resourceMetadataFactory; + $this->filters = $filters; + $this->operationMethodResolver = $operationMethodResolver; + $this->router = $router; + } + + /** + * {@inheritdoc} + */ + public function getAnnotations() : array + { + $annotations = []; + $hydraDoc = $this->apiDocumentationBuilder->getApiDocumentation(); + $entrypointHydraDoc = $this->getResourceHydraDoc($hydraDoc, '#Entrypoint'); + + foreach ($this->resourceNameCollectionFactory->create() as $resourceClass) { + $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass); + + $prefixedShortName = ($iri = $resourceMetadata->getIri()) ? $iri : '#'.$resourceMetadata->getShortName(); + $resourceHydraDoc = $this->getResourceHydraDoc($hydraDoc, $prefixedShortName); + + if ($hydraDoc) { + foreach ($resourceMetadata->getCollectionOperations() as $operationName => $operation) { + $annotations[] = $this->getApiDoc(true, $resourceClass, $resourceMetadata, $operationName, $operation, $resourceHydraDoc, $entrypointHydraDoc); + } + + foreach ($resourceMetadata->getItemOperations() as $operationName => $operation) { + $annotations[] = $this->getApiDoc(false, $resourceClass, $resourceMetadata, $operationName, $operation, $resourceHydraDoc); + } + } + } + + return $annotations; + } + + /** + * Builds ApiDoc annotation from ApiPlatform data. + * + * @param bool $collection + * @param string $resourceClass + * @param ResourceMetadata $resourceMetadata + * @param string $operationName + * @param array $operation + * @param array $resourceHydraDoc + * @param array $entrypointHydraDoc + * + * @return ApiDoc + */ + private function getApiDoc(bool $collection, string $resourceClass, ResourceMetadata $resourceMetadata, string $operationName, array $operation, array $resourceHydraDoc, array $entrypointHydraDoc = []) : ApiDoc + { + if ($collection) { + $method = $this->operationMethodResolver->getCollectionOperationMethod($resourceClass, $operationName); + $operationHydraDoc = $this->getCollectionOperationHydraDoc($resourceMetadata->getShortName(), $method, $entrypointHydraDoc); + } else { + $method = $this->operationMethodResolver->getItemOperationMethod($resourceClass, $operationName); + $operationHydraDoc = $this->getOperationHydraDoc($method, $resourceHydraDoc); + } + + $route = $this->getRoute($resourceClass, $collection, $operationName); + + $data = [ + 'resource' => $route->getPath(), + 'description' => $operationHydraDoc['hydra:title'], + 'resourceDescription' => $resourceHydraDoc['hydra:title'], + 'section' => $resourceHydraDoc['hydra:title'], + ]; + + if (isset($operationHydraDoc['expects']) && 'owl:Nothing' !== $operationHydraDoc['expects']) { + $data['input'] = sprintf('%s:%s', ApiPlatformParser::IN_PREFIX, $resourceClass); + } + + if (isset($operationHydraDoc['returns']) && 'owl:Nothing' !== $operationHydraDoc['returns']) { + $data['output'] = sprintf('%s:%s', ApiPlatformParser::OUT_PREFIX, $resourceClass); + } + + if ($collection && Request::METHOD_GET === $method) { + $resourceFilters = $resourceMetadata->getCollectionOperationAttribute($operationName, 'filters', [], true); + + $data['filters'] = []; + foreach ($this->filters as $filterName => $filter) { + if (in_array($filterName, $resourceFilters)) { + foreach ($filter->getDescription($resource) as $name => $definition) { + $data['filters'][] = ['name' => $name] + $definition; + } + } + } + } + + $apiDoc = new ApiDoc($data); + $apiDoc->setRoute($route); + + return $apiDoc; + } + + /** + * Gets Hydra documentation for the given resource. + * + * @param array $hydraApiDoc + * @param string $prefixedShortName + * + * @return array|null + */ + private function getResourceHydraDoc(array $hydraApiDoc, string $prefixedShortName) + { + foreach ($hydraApiDoc['hydra:supportedClass'] as $supportedClass) { + if ($supportedClass['@id'] === $prefixedShortName) { + return $supportedClass; + } + } + } + + /** + * Gets the Hydra documentation of a given operation. + * + * @param string $method + * @param array $hydraDoc + * + * @return array|null + */ + private function getOperationHydraDoc(string $method, array $hydraDoc) + { + foreach ($hydraDoc['hydra:supportedOperation'] as $supportedOperation) { + if ($supportedOperation['hydra:method'] === $method) { + return $supportedOperation; + } + } + } + + /** + * Gets the Hydra documentation for the collection operation. + * + * @param string $shortName + * @param string $method + * @param array $hydraEntrypointDoc + * + * @return array|null + */ + private function getCollectionOperationHydraDoc(string $shortName, string $method, array $hydraEntrypointDoc) + { + $propertyName = '#Entrypoint/'.lcfirst($shortName); + + foreach ($hydraEntrypointDoc['hydra:supportedProperty'] as $supportedProperty) { + $hydraProperty = $supportedProperty['hydra:property']; + if ($hydraProperty['@id'] === $propertyName) { + return $this->getOperationHydraDoc($method, $hydraProperty); + } + } + } + + /** + * Finds the route for an operation on a resource. + * + * @param string $resourceClass + * @param bool $collection + * @param string $operationName + * + * @return Route + * + * @throws \InvalidArgumentException + */ + private function getRoute(string $resourceClass, bool $collection, string $operationName) : Route + { + $operationNameKey = sprintf('_%s_operation_name', $collection ? 'collection' : 'item'); + $found = false; + + foreach ($this->router->getRouteCollection()->all() as $routeName => $route) { + $currentResourceClass = $route->getDefault('_resource_class'); + $currentOperationName = $route->getDefault($operationNameKey); + + if ($resourceClass === $currentResourceClass && $operationName === $currentOperationName) { + $found = true; + break; + } + } + + if (!$found) { + throw new \InvalidArgumentException(sprintf('No route found for operation "%s" for type "%s".', $operationName, $resourceClass)); + } + + return $route; + } +} diff --git a/src/Bridge/NelmioApiDoc/Parser/ApiPlatformParser.php b/src/Bridge/NelmioApiDoc/Parser/ApiPlatformParser.php new file mode 100644 index 00000000000..b2aeff6d912 --- /dev/null +++ b/src/Bridge/NelmioApiDoc/Parser/ApiPlatformParser.php @@ -0,0 +1,204 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace ApiPlatform\Core\Bridge\NelmioApiDoc\Parser; + +use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; +use ApiPlatform\Core\Metadata\Property\PropertyMetadata; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; +use Nelmio\ApiDocBundle\DataTypes; +use Nelmio\ApiDocBundle\Parser\ParserInterface; +use Symfony\Component\PropertyInfo\Type; + +/** + * Extract input and output information for the NelmioApiDocBundle. + * + * @author Kévin Dunglas + */ +final class ApiPlatformParser implements ParserInterface +{ + const IN_PREFIX = 'api_platform_in'; + const OUT_PREFIX = 'api_platform_out'; + const TYPE_IRI = 'IRI'; + const TYPE_MAP = [ + Type::BUILTIN_TYPE_BOOL => DataTypes::BOOLEAN, + Type::BUILTIN_TYPE_FLOAT => DataTypes::FLOAT, + Type::BUILTIN_TYPE_INT => DataTypes::INTEGER, + Type::BUILTIN_TYPE_STRING => DataTypes::STRING, + ]; + + private $resourceNameCollectionFactory; + private $resourceMetadataFactory; + private $propertyNameCollectionFactory; + private $propertyMetadataFactory; + + public function __construct(ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, ResourceMetadataFactoryInterface $resourceMetadataFactory, PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory) + { + $this->resourceNameCollectionFactory = $resourceNameCollectionFactory; + $this->resourceMetadataFactory = $resourceMetadataFactory; + $this->propertyNameCollectionFactory = $propertyNameCollectionFactory; + $this->propertyMetadataFactory = $propertyMetadataFactory; + } + + /** + * {@inheritdoc} + */ + public function supports(array $item) : bool + { + $data = explode(':', $item['class'], 2); + if (isset($data[1])) { + foreach ($this->resourceNameCollectionFactory->create() as $resourceClass) { + if ($data[1] === $resourceClass) { + return true; + } + } + } + + return false; + } + + /** + * {@inheritdoc} + */ + public function parse(array $item) : array + { + list($io, $resourceClass) = explode(':', $item['class'], 2); + $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass); + + return $this->parseClass($resourceMetadata, $resourceClass, $io); + } + + /** + * Parses a class. + * + * @param ResourceMetadata $resourceMetadata + * @param string $resourceClass + * @param string $io + * @param string[] $visited + * + * @return array + */ + private function parseClass(ResourceMetadata $resourceMetadata, string $resourceClass, string $io, array $visited = []) : array + { + $visited[] = $resourceClass; + + $attributes = $resourceMetadata->getAttributes(); + $options = []; + $data = []; + + if (isset($attributes['normalization_context']['groups'])) { + $options['serializer_groups'] = $attributes['normalization_context']['groups']; + } + + if (isset($attributes['denormalization_context']['groups'])) { + $options['serializer_groups'] = isset($options['serializer_groups']) ? array_merge($options['serializer_groups'], $attributes['denormalization_context']['groups']) : $options['serializer_groups']; + } + + foreach ($this->propertyNameCollectionFactory->create($resourceClass, $options) as $propertyName) { + $propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $propertyName); + + if ( + ($propertyMetadata->isReadable() && self::OUT_PREFIX === $io) || + ($propertyMetadata->isWritable() && self::IN_PREFIX === $io) + ) { + $data[$name] = $this->parseProperty($resourceMetadata, $propertyMetadata, $io, null, $visited); + } + } + + return $data; + } + + /** + * Parses a property. + * + * @param ResourceMetadata $resourceMetadata + * @param PropertyMetadata $propertyMetadata + * @param string $io + * @param Type|null $type + * @param string[] $visited + * + * @return array + */ + private function parseProperty(ResourceMetadata $resourceMetadata, PropertyMetadata $propertyMetadata, $io, Type $type = null, array $visited = []) + { + $data = [ + 'dataType' => null, + 'required' => $propertyMetadata->isRequired(), + 'description' => $propertyMetadata->getDescription(), + 'readonly' => !$propertyMetadata->isWritable(), + ]; + + if (null == $type) { + $type = $propertyMetadata->getType(); + + if (null === $type) { + // Default to string + $data['dataType'] = DataTypes::STRING; + + return $data; + } + } + + if ($type->isCollection()) { + $data['actualType'] = DataTypes::COLLECTION; + + if ($collectionType = $type->getCollectionValueType()) { + $subProperty = $this->parseProperty($resourceMetadata, $propertyMetadata, $io, $collectionType, $visited); + if (self::TYPE_IRI === $subProperty['dataType']) { + $data['dataType'] = 'array of IRIs'; + $data['subType'] = DataTypes::STRING; + + return $data; + } + + $data['subType'] = $subProperty['subType']; + $data['children'] = $subProperty['children']; + } + + return $data; + } + + $builtinType = $type->getBuiltinType(); + if ('object' === $builtinType) { + $className = $type->getClassName(); + + if (is_subclass_of($className, \DateTimeInterface::class)) { + $data['dataType'] = DataTypes::DATETIME; + $data['format'] = sprintf('{DateTime %s}', \DateTime::RFC3339); + + return $data; + } + + if ( + (self::OUT_PREFIX === $io && $propertyMetadata->isReadableLink()) || + (self::IN_PREFIX === $io && $propertyMetadata->isWritableLink()) + ) { + $data['dataType'] = self::TYPE_IRI; + $data['actualType'] = DataTypes::STRING; + + return $data; + } + + $data['actualType'] = DataTypes::MODEL; + $data['subType'] = $className; + $data['children'] = in_array($className, $visited) ? [] : $this->parseClass($resourceMetadata, $className, $io, $visited); + + return $data; + } + + $data['dataType'] = self::TYPE_MAP[$builtinType] ?? DataTypes::STRING; + + return $data; + } +} diff --git a/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index 4e5e57748d4..c1af74f2f58 100644 --- a/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -86,6 +86,11 @@ public function load(array $configs, ContainerBuilder $container) if ($config['enable_fos_user']) { $loader->load('fos_user.xml'); } + + // NelmioApiDoc support + if ($config['nelmio_api_doc']) { + $loader->load('nelmio_api_doc.xml'); + } } /** diff --git a/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php b/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php index 3ff26471e63..9200e51bbb5 100644 --- a/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php +++ b/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php @@ -59,6 +59,7 @@ public function getConfigTreeBuilder() ->end() ->end() ->booleanNode('enable_fos_user')->defaultValue(false)->info('Enable the FOSUserBundle integration.')->end() + ->booleanNode('nelmio_api_doc')->defaultValue(false)->info('Enable the Nelmio Api doc integration.')->end() ->arrayNode('collection') ->addDefaultsIfNotSet() ->children() diff --git a/src/Bridge/Symfony/Bundle/Resources/config/nelmio_api_doc.xml b/src/Bridge/Symfony/Bundle/Resources/config/nelmio_api_doc.xml new file mode 100644 index 00000000000..0495ddd8b80 --- /dev/null +++ b/src/Bridge/Symfony/Bundle/Resources/config/nelmio_api_doc.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/Bridge/Symfony/Bundle/DependencyInjection/ConfigurationTest.php b/tests/Bridge/Symfony/Bundle/DependencyInjection/ConfigurationTest.php index 3a23d0ff0f6..2a6fe1f37f8 100644 --- a/tests/Bridge/Symfony/Bundle/DependencyInjection/ConfigurationTest.php +++ b/tests/Bridge/Symfony/Bundle/DependencyInjection/ConfigurationTest.php @@ -35,6 +35,7 @@ public function testDefaultConfig() 'description' => 'description', 'supported_formats' => ['jsonld' => ['mime_types' => ['application/ld+json']]], 'enable_fos_user' => false, + 'nelmio_api_doc' => false, 'collection' => [ 'order' => null, 'order_parameter_name' => 'order',