From 62e2d55818e57078b90a9ffc6b95f08ae9a712f5 Mon Sep 17 00:00:00 2001 From: Pierre du Plessis Date: Tue, 20 Jun 2017 15:48:10 +0200 Subject: [PATCH] Add SubResource to swagger docs --- src/Metadata/Resource/SubResourceMetadata.php | 62 +++++++++ .../Serializer/DocumentationNormalizer.php | 78 ++++++++--- .../DocumentationNormalizerTest.php | 126 +++++++++++++++++- 3 files changed, 244 insertions(+), 22 deletions(-) create mode 100644 src/Metadata/Resource/SubResourceMetadata.php diff --git a/src/Metadata/Resource/SubResourceMetadata.php b/src/Metadata/Resource/SubResourceMetadata.php new file mode 100644 index 00000000000..93f3caca607 --- /dev/null +++ b/src/Metadata/Resource/SubResourceMetadata.php @@ -0,0 +1,62 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Metadata\Resource; + +final class SubResourceMetadata +{ + private $parent; + private $property; + private $isCollection; + private $parentResourceClass; + + public function __construct(ResourceMetadata $parent, string $property, bool $isCollection, string $parentResourceClass) + { + $this->parent = $parent; + $this->property = $property; + $this->isCollection = $isCollection; + $this->parentResourceClass = $parentResourceClass; + } + + /** + * @return ResourceMetadata + */ + public function getParent(): ResourceMetadata + { + return $this->parent; + } + + /** + * @return string + */ + public function getProperty(): string + { + return $this->property; + } + + /** + * @return bool + */ + public function isCollection(): bool + { + return $this->isCollection; + } + + /** + * @return string + */ + public function getParentResourceClass(): string + { + return $this->parentResourceClass; + } +} diff --git a/src/Swagger/Serializer/DocumentationNormalizer.php b/src/Swagger/Serializer/DocumentationNormalizer.php index 54e2267807f..34a0d10e400 100644 --- a/src/Swagger/Serializer/DocumentationNormalizer.php +++ b/src/Swagger/Serializer/DocumentationNormalizer.php @@ -25,6 +25,7 @@ use ApiPlatform\Core\Metadata\Property\PropertyMetadata; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; +use ApiPlatform\Core\Metadata\Resource\SubResourceMetadata; use ApiPlatform\Core\PathResolver\OperationPathResolverInterface; use Psr\Container\ContainerInterface; use Symfony\Component\PropertyInfo\Type; @@ -98,6 +99,8 @@ public function normalize($object, $format = null, array $context = []) $this->addPaths($paths, $definitions, $resourceClass, $resourceShortName, $resourceMetadata, $mimeTypes, OperationType::COLLECTION); $this->addPaths($paths, $definitions, $resourceClass, $resourceShortName, $resourceMetadata, $mimeTypes, OperationType::ITEM); + + $this->processSubResources($paths, $definitions, $resourceClass, $resourceMetadata, $mimeTypes); } $definitions->ksort(); @@ -109,25 +112,34 @@ public function normalize($object, $format = null, array $context = []) /** * Updates the list of entries in the paths collection. * - * @param \ArrayObject $paths - * @param \ArrayObject $definitions - * @param string $resourceClass - * @param string $resourceShortName - * @param ResourceMetadata $resourceMetadata - * @param array $mimeTypes - * @param string $operationType + * @param \ArrayObject $paths + * @param \ArrayObject $definitions + * @param string $resourceClass + * @param string $resourceShortName + * @param ResourceMetadata $resourceMetadata + * @param array $mimeTypes + * @param string $operationType + * @param SubResourceMetadata $subResource */ - private function addPaths(\ArrayObject $paths, \ArrayObject $definitions, string $resourceClass, string $resourceShortName, ResourceMetadata $resourceMetadata, array $mimeTypes, string $operationType) + private function addPaths(\ArrayObject $paths, \ArrayObject $definitions, string $resourceClass, string $resourceShortName, ResourceMetadata $resourceMetadata, array $mimeTypes, string $operationType, SubResourceMetadata $subResource = null) { - if (null === $operations = $operationType === OperationType::COLLECTION ? $resourceMetadata->getCollectionOperations() : $resourceMetadata->getItemOperations()) { + if ($subResource) { + $operations = $resourceMetadata->getCollectionOperations(); + } else if (null === $operations = $operationType === OperationType::ITEM ? $resourceMetadata->getItemOperations() : $resourceMetadata->getCollectionOperations()) { return; } foreach ($operations as $operationName => $operation) { + if ($subResource) { + $operation['identifiers'] = [['id', $subResource->getParentResourceClass()]]; + $operation['property'] = $subResource->getProperty(); + $operation['collection'] = $subResource->isCollection(); + } + $path = $this->getPath($resourceShortName, $operation, $operationType); $method = $operationType === OperationType::ITEM ? $this->operationMethodResolver->getItemOperationMethod($resourceClass, $operationName) : $this->operationMethodResolver->getCollectionOperationMethod($resourceClass, $operationName); - $paths[$path][strtolower($method)] = $this->getPathOperation($operationName, $operation, $method, $operationType, $resourceClass, $resourceMetadata, $mimeTypes, $definitions); + $paths[$path][strtolower($method)] = $this->getPathOperation($operationName, $operation, $method, $operationType, $resourceClass, $resourceMetadata, $mimeTypes, $definitions, $subResource); } } @@ -160,22 +172,23 @@ private function getPath(string $resourceShortName, array $operation, string $op * * @see https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md#operation-object * - * @param string $operationName - * @param array $operation - * @param string $method - * @param string $operationType - * @param string $resourceClass - * @param ResourceMetadata $resourceMetadata - * @param string[] $mimeTypes - * @param \ArrayObject $definitions + * @param string $operationName + * @param array $operation + * @param string $method + * @param string $operationType + * @param string $resourceClass + * @param ResourceMetadata $resourceMetadata + * @param string[] $mimeTypes + * @param \ArrayObject $definitions + * @param SubResourceMetadata $subResource * * @return \ArrayObject */ - private function getPathOperation(string $operationName, array $operation, string $method, string $operationType, string $resourceClass, ResourceMetadata $resourceMetadata, array $mimeTypes, \ArrayObject $definitions): \ArrayObject + private function getPathOperation(string $operationName, array $operation, string $method, string $operationType, string $resourceClass, ResourceMetadata $resourceMetadata, array $mimeTypes, \ArrayObject $definitions, SubResourceMetadata $subResource = null): \ArrayObject { $pathOperation = new \ArrayObject($operation['swagger_context'] ?? []); $resourceShortName = $resourceMetadata->getShortName(); - $pathOperation['tags'] ?? $pathOperation['tags'] = [$resourceShortName]; + $pathOperation['tags'] ?? $pathOperation['tags'] = [$subResource ? $subResource->getParent()->getShortName() : $resourceShortName]; $pathOperation['operationId'] ?? $pathOperation['operationId'] = lcfirst($operationName).ucfirst($resourceShortName).ucfirst($operationType); switch ($method) { @@ -636,4 +649,29 @@ private function getSerializerContext(string $operationType, bool $denormalizati return $resourceMetadata->getItemOperationAttribute($operationName, $contextKey, null, true); } + + /** + * @param \ArrayObject $paths + * @param \ArrayObject $definitions + * @param string $parentResourceClass + * @param ResourceMetadata $parentResourceMetadata + * @param array $mimeTypes + */ + private function processSubResources(\ArrayObject $paths, \ArrayObject $definitions, string $parentResourceClass, ResourceMetadata $parentResourceMetadata, array $mimeTypes) + { + $serializerContext = $this->getSerializerContext(OperationType::SUBRESOURCE, false, $parentResourceMetadata, 'get'); + $options = isset($serializerContext['groups']) ? ['serializer_groups' => $serializerContext['groups']] : []; + foreach ($this->propertyNameCollectionFactory->create($parentResourceClass, $options) as $property) { + $propertyMetadata = $this->propertyMetadataFactory->create($parentResourceClass, $property); + if ($propertyMetadata->hasSubresource()) { + $isCollection = $propertyMetadata->getType()->isCollection(); + $resourceClass = $isCollection ? $propertyMetadata->getType()->getCollectionValueType()->getClassName() : $propertyMetadata->getType()->getClassName(); + $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass); + + $subResourceMetadata = new SubResourceMetadata($parentResourceMetadata, $property, $isCollection, $parentResourceClass); + + $this->addPaths($paths, $definitions, $resourceClass, $parentResourceMetadata->getShortName(), $resourceMetadata, $mimeTypes, OperationType::SUBRESOURCE, $subResourceMetadata); + } + } + } } diff --git a/tests/Swagger/Serializer/DocumentationNormalizerTest.php b/tests/Swagger/Serializer/DocumentationNormalizerTest.php index c1a81f647bc..e4595da59ce 100644 --- a/tests/Swagger/Serializer/DocumentationNormalizerTest.php +++ b/tests/Swagger/Serializer/DocumentationNormalizerTest.php @@ -30,7 +30,9 @@ use ApiPlatform\Core\PathResolver\UnderscoreOperationPathResolver; use ApiPlatform\Core\Swagger\Serializer\DocumentationNormalizer; use ApiPlatform\Core\Tests\Fixtures\DummyFilter; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Answer; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Question; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\RelatedDummy; use Psr\Container\ContainerInterface; use Symfony\Component\PropertyInfo\Type; @@ -247,6 +249,124 @@ public function testNormalize() $this->assertEquals($expected, $normalizer->normalize($documentation)); } + public function testNormalizeWithSubResource() + { + $documentation = new Documentation(new ResourceNameCollection([Question::class]), 'Test API', 'This is a test API.', '1.2.3', ['jsonld' => ['application/ld+json']]); + + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(Question::class, [])->shouldBeCalled()->willReturn(new PropertyNameCollection(['answer'])); + $propertyNameCollectionFactoryProphecy->create(Answer::class, [])->shouldBeCalled()->willReturn(new PropertyNameCollection(['content'])); + + $questionMetadata = new ResourceMetadata('Question', 'This is a question.', 'http://schema.example.com/Question', ['get' => ['method' => 'GET']]); + $answerMetadata = new ResourceMetadata('Answer', 'This is an answer.', 'http://schema.example.com/Answer', [], ['get' => ['method' => 'GET']]); + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataFactoryProphecy->create(Question::class)->shouldBeCalled()->willReturn($questionMetadata); + $resourceMetadataFactoryProphecy->create(Answer::class)->shouldBeCalled()->willReturn($answerMetadata); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Question::class, 'answer')->shouldBeCalled()->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_OBJECT, false, Question::class, true, null, new Type(Type::BUILTIN_TYPE_OBJECT, false, Answer::class)), 'This is a name.', true, true, true, true, false, false, null, null, [], true)); + $propertyMetadataFactoryProphecy->create(Answer::class, 'content')->shouldBeCalled()->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_OBJECT, false, Question::class, true, null, new Type(Type::BUILTIN_TYPE_OBJECT, false, Answer::class)), 'This is a name.', true, true, true, true, false, false, null, null, [], true)); + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->isResourceClass(Question::class)->willReturn(true); + $resourceClassResolverProphecy->isResourceClass(Answer::class)->willReturn(true); + + $operationMethodResolverProphecy = $this->prophesize(OperationMethodResolverInterface::class); + $operationMethodResolverProphecy->getItemOperationMethod(Question::class, 'get')->shouldBeCalled()->willReturn('GET'); + $operationMethodResolverProphecy->getCollectionOperationMethod(Answer::class, 'get')->shouldBeCalled()->willReturn('GET'); + + $urlGeneratorProphecy = $this->prophesize(UrlGeneratorInterface::class); + $urlGeneratorProphecy->generate('api_entrypoint')->willReturn('/app_dev.php/')->shouldBeCalled(); + + $operationPathResolver = new CustomOperationPathResolver(new UnderscoreOperationPathResolver()); + + $normalizer = new DocumentationNormalizer( + $resourceMetadataFactoryProphecy->reveal(), + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $resourceClassResolverProphecy->reveal(), + $operationMethodResolverProphecy->reveal(), + $operationPathResolver, + $urlGeneratorProphecy->reveal() + ); + + $expected = [ + 'swagger' => '2.0', + 'basePath' => '/app_dev.php/', + 'info' => [ + 'title' => 'Test API', + 'description' => 'This is a test API.', + 'version' => '1.2.3', + ], + 'paths' => new \ArrayObject([ + '/questions/{id}' => [ + 'get' => new \ArrayObject([ + 'tags' => ['Question'], + 'operationId' => 'getQuestionItem', + 'produces' => ['application/ld+json'], + 'summary' => 'Retrieves a Question resource.', + 'parameters' => [ + [ + 'name' => 'id', + 'in' => 'path', + 'type' => 'integer', + 'required' => true, + ], + ], + 'responses' => [ + 200 => [ + 'description' => 'Question resource response', + 'schema' => ['$ref' => '#/definitions/Question'], + ], + 404 => ['description' => 'Resource not found'], + ], + ]), + ], + '/questions/{id}/answers' => [ + 'get' => new \ArrayObject([ + 'tags' => ['Question'], + 'operationId' => 'getAnswerSubresource', + 'produces' => ['application/ld+json'], + 'summary' => 'Retrieves the collection of Answer resources.', + 'responses' => [ + 200 => [ + 'description' => 'Answer collection response', + 'schema' => ['type' => 'array', 'items' => ['$ref' => '#/definitions/Answer']], + ], + ], + ]), + ], + ]), + 'definitions' => new \ArrayObject([ + 'Question' => new \ArrayObject([ + 'type' => 'object', + 'description' => 'This is a question.', + 'externalDocs' => ['url' => 'http://schema.example.com/Question'], + 'properties' => [ + 'answer' => new \ArrayObject([ + 'type' => 'array', + 'description' => 'This is a name.', + 'items' => ['$ref' => '#/definitions/Answer'], + ]), + ], + ]), + 'Answer' => new \ArrayObject([ + 'type' => 'object', + 'description' => 'This is an answer.', + 'externalDocs' => ['url' => 'http://schema.example.com/Answer'], + 'properties' => [ + 'content' => new \ArrayObject([ + 'type' => 'array', + 'description' => 'This is a name.', + 'items' => ['$ref' => '#/definitions/Answer'] + ]), + ], + ]), + ]), + ]; + + $this->assertEquals($expected, $normalizer->normalize($documentation)); + } + public function testNormalizeWithNameConverter() { $documentation = new Documentation(new ResourceNameCollection([Dummy::class]), 'Dummy API', 'This is a dummy API', '1.2.3', ['jsonld' => ['application/ld+json']]); @@ -1028,7 +1148,7 @@ public function testNoOperations() $documentation = new Documentation(new ResourceNameCollection([Dummy::class]), '', '', '0.0.0', ['jsonld' => ['application/ld+json']]); $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); - $propertyNameCollectionFactoryProphecy->create(Dummy::class, [])->shouldNotBeCalled(); + $propertyNameCollectionFactoryProphecy->create(Dummy::class, [])->shouldBeCalled()->willReturn(new PropertyNameCollection(['name'])); $dummyMetadata = new ResourceMetadata( 'Dummy', @@ -1042,7 +1162,7 @@ public function testNoOperations() $resourceMetadataFactoryProphecy->create(Dummy::class)->shouldBeCalled()->willReturn($dummyMetadata); $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'name')->shouldNotBeCalled(); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'name')->shouldBeCalled()->willReturn(new PropertyMetadata()); $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); @@ -1083,6 +1203,7 @@ public function testWithCustomMethod() $documentation = new Documentation(new ResourceNameCollection([Dummy::class]), '', '', '0.0.0', ['jsonld' => ['application/ld+json']]); $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(Dummy::class, [])->shouldBeCalled()->willReturn(new PropertyNameCollection(['name'])); $dummyMetadata = new ResourceMetadata( 'Dummy', @@ -1096,6 +1217,7 @@ public function testWithCustomMethod() $resourceMetadataFactoryProphecy->create(Dummy::class)->shouldBeCalled()->willReturn($dummyMetadata); $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'name')->shouldBeCalled()->willReturn(new PropertyMetadata()); $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true);