From 6558e19b4ec763cf674238753755d52e2d3c30a7 Mon Sep 17 00:00:00 2001 From: jocelyn fournier Date: Mon, 17 Jun 2019 15:39:05 +0200 Subject: [PATCH 001/160] add ability to control the max header length allowed by the varnish server to avoid overflow during BAN command --- .../ApiPlatformExtension.php | 4 +- .../DependencyInjection/Configuration.php | 4 ++ src/HttpCache/VarnishPurger.php | 41 ++++++++++++++++++- .../ApiPlatformExtensionTest.php | 1 + .../DependencyInjection/ConfigurationTest.php | 1 + 5 files changed, 49 insertions(+), 2 deletions(-) diff --git a/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index ccbf91515a8..ef1176442f1 100644 --- a/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -179,6 +179,7 @@ private function registerCommonConfiguration(ContainerBuilder $container, array $container->setParameter('api_platform.http_cache.shared_max_age', $config['http_cache']['shared_max_age']); $container->setParameter('api_platform.http_cache.vary', $config['http_cache']['vary']); $container->setParameter('api_platform.http_cache.public', $config['http_cache']['public']); + $container->setParameter('api_platform.http_cache.invalidation.max_header_length', $config['http_cache']['invalidation']['max_header_length']); $container->setAlias('api_platform.operation_path_resolver.default', $config['default_operation_path_resolver']); $container->setAlias('api_platform.path_segment_name_generator', $config['path_segment_name_generator']); @@ -493,7 +494,8 @@ private function registerHttpCacheConfiguration(ContainerBuilder $container, arr $definitions[] = $definition; } - $container->getDefinition('api_platform.http_cache.purger.varnish')->addArgument($definitions); + $container->getDefinition('api_platform.http_cache.purger.varnish')->setArguments([$definitions, + $config['http_cache']['invalidation']['max_header_length']]); $container->setAlias('api_platform.http_cache.purger', 'api_platform.http_cache.purger.varnish'); } diff --git a/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php b/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php index 7ab0c5da92e..db26d739d1e 100644 --- a/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php +++ b/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php @@ -320,6 +320,10 @@ private function addHttpCacheSection(ArrayNodeDefinition $rootNode): void ->prototype('scalar')->end() ->info('URLs of the Varnish servers to purge using cache tags when a resource is updated.') ->end() + ->integerNode('max_header_length') + ->defaultValue(7500) + ->info('Max header length supported by the server') + ->end() ->variableNode('request_options') ->defaultValue([]) ->validate() diff --git a/src/HttpCache/VarnishPurger.php b/src/HttpCache/VarnishPurger.php index 5f317639314..0811459d0ae 100644 --- a/src/HttpCache/VarnishPurger.php +++ b/src/HttpCache/VarnishPurger.php @@ -24,14 +24,43 @@ */ final class VarnishPurger implements PurgerInterface { + private $maxHeaderLength; private $clients; /** * @param ClientInterface[] $clients + * @param int $maxHeaderLength */ - public function __construct(array $clients) + public function __construct(array $clients, $maxHeaderLength = 7500) { $this->clients = $clients; + $this->maxHeaderLength = $maxHeaderLength; + } + + /** + * Calculate how many tags fit into the header. + * + * This assumes that the tags are separated by one character. + * + * From https://github.com/FriendsOfSymfony/FOSHttpCache/blob/master/src/ProxyClient/HttpProxyClient.php#L137 + * + * @param string[] $escapedTags + * @param string $glue The concatenation string to use + * + * @return int Number of tags per tag invalidation request + */ + private function determineTagsPerHeader($escapedTags, $glue) + { + if (mb_strlen(implode($glue, $escapedTags)) < $this->maxHeaderLength) { + return \count($escapedTags); + } + /* + * estimate the amount of tags to invalidate by dividing the max + * header length by the largest tag (minus the glue length) + */ + $tagsize = max(array_map('mb_strlen', $escapedTags)); + + return (int) floor($this->maxHeaderLength / ($tagsize + \strlen($glue))) ?: 1; } /** @@ -43,6 +72,16 @@ public function purge(array $iris) return; } + $chunkSize = $this->determineTagsPerHeader($iris, '|'); + + $irisChunks = array_chunk($iris, $chunkSize); + foreach ($irisChunks as $irisChunk) { + $this->purgeRequest($irisChunk); + } + } + + private function purgeRequest(array $iris) + { // Create the regex to purge all tags in just one request $parts = array_map(function ($iri) { return sprintf('(^|\,)%s($|\,)', preg_quote($iri)); diff --git a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php index 87d3de7c536..dc920a8d4bf 100644 --- a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php +++ b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php @@ -798,6 +798,7 @@ private function getPartialContainerBuilderProphecy() 'api_platform.http_cache.shared_max_age' => null, 'api_platform.http_cache.vary' => ['Accept'], 'api_platform.http_cache.public' => null, + 'api_platform.http_cache.invalidation.max_header_length' => 7500, 'api_platform.enable_entrypoint' => true, 'api_platform.enable_docs' => true, ]; diff --git a/tests/Bridge/Symfony/Bundle/DependencyInjection/ConfigurationTest.php b/tests/Bridge/Symfony/Bundle/DependencyInjection/ConfigurationTest.php index c631b7b1fe9..77889a269ad 100644 --- a/tests/Bridge/Symfony/Bundle/DependencyInjection/ConfigurationTest.php +++ b/tests/Bridge/Symfony/Bundle/DependencyInjection/ConfigurationTest.php @@ -176,6 +176,7 @@ private function runDefaultConfigTests(array $doctrineIntegrationsToLoad = ['orm 'enabled' => false, 'varnish_urls' => [], 'request_options' => [], + 'max_header_length' => 7500, ], 'etag' => true, 'max_age' => null, From 9ab149df66448ac281b1e08859e41de8422e65a1 Mon Sep 17 00:00:00 2001 From: jocelyn fournier Date: Thu, 22 Aug 2019 13:55:37 +0200 Subject: [PATCH 002/160] csfixer --- .../Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index ef1176442f1..66e9fb75cb0 100644 --- a/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -495,7 +495,7 @@ private function registerHttpCacheConfiguration(ContainerBuilder $container, arr } $container->getDefinition('api_platform.http_cache.purger.varnish')->setArguments([$definitions, - $config['http_cache']['invalidation']['max_header_length']]); + $config['http_cache']['invalidation']['max_header_length'], ]); $container->setAlias('api_platform.http_cache.purger', 'api_platform.http_cache.purger.varnish'); } From b85bf34c1473d6e590a18dddd755f462a09b329c Mon Sep 17 00:00:00 2001 From: jocelyn fournier Date: Fri, 23 Aug 2019 12:01:53 +0200 Subject: [PATCH 003/160] add test for max_header_length --- tests/HttpCache/VarnishPurgerTest.php | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/HttpCache/VarnishPurgerTest.php b/tests/HttpCache/VarnishPurgerTest.php index 82591fd5099..0460a86df1c 100644 --- a/tests/HttpCache/VarnishPurgerTest.php +++ b/tests/HttpCache/VarnishPurgerTest.php @@ -33,9 +33,20 @@ public function testPurge() $clientProphecy2->request('BAN', '', ['headers' => ['ApiPlatform-Ban-Regex' => '(^|\,)/foo($|\,)']])->willReturn(new Response())->shouldBeCalled(); $clientProphecy2->request('BAN', '', ['headers' => ['ApiPlatform-Ban-Regex' => '((^|\,)/foo($|\,))|((^|\,)/bar($|\,))']])->willReturn(new Response())->shouldBeCalled(); + $clientProphecy3 = $this->prophesize(ClientInterface::class); + $clientProphecy3->request('BAN', '', ['headers' => ['ApiPlatform-Ban-Regex' => '(^|\,)/foo($|\,)']])->willReturn(new Response())->shouldBeCalled(); + $clientProphecy3->request('BAN', '', ['headers' => ['ApiPlatform-Ban-Regex' => '(^|\,)/bar($|\,)']])->willReturn(new Response())->shouldBeCalled(); + + $clientProphecy4 = $this->prophesize(ClientInterface::class); + $clientProphecy4->request('BAN', '', ['headers' => ['ApiPlatform-Ban-Regex' => '(^|\,)/foo($|\,)']])->willReturn(new Response())->shouldBeCalled(); + $clientProphecy4->request('BAN', '', ['headers' => ['ApiPlatform-Ban-Regex' => '(^|\,)/bar($|\,)']])->willReturn(new Response())->shouldBeCalled(); + $purger = new VarnishPurger([$clientProphecy1->reveal(), $clientProphecy2->reveal()]); $purger->purge(['/foo']); $purger->purge(['/foo' => '/foo', '/bar' => '/bar']); + + $purger = new VarnishPurger([$clientProphecy3->reveal(), $clientProphecy4->reveal()], 5); + $purger->purge(['/foo' => '/foo', '/bar' => '/bar']); } public function testEmptyTags() From 3366849cc9df77e4cf0f57e915a7e577a3b48a10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Dunglas?= Date: Mon, 23 Sep 2019 14:39:25 +0200 Subject: [PATCH 004/160] Update branch alias --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 300a0bbf3bc..7674b2dc056 100644 --- a/composer.json +++ b/composer.json @@ -125,7 +125,7 @@ }, "extra": { "branch-alias": { - "dev-master": "2.5.x-dev" + "dev-master": "2.6.x-dev" } } } From 854bcc9e4684a0b88da1ff6ddc56d9e6c100b551 Mon Sep 17 00:00:00 2001 From: Michel Roca Date: Fri, 27 Sep 2019 11:13:53 +0200 Subject: [PATCH 005/160] Add entrypoints sorting --- features/bootstrap/JsonApiContext.php | 20 +++++++++++++++++++ features/hydra/entrypoint.feature | 1 + src/Hydra/Serializer/EntrypointNormalizer.php | 2 ++ .../Serializer/EntrypointNormalizerTest.php | 6 +++++- 4 files changed, 28 insertions(+), 1 deletion(-) diff --git a/features/bootstrap/JsonApiContext.php b/features/bootstrap/JsonApiContext.php index ff89b039704..bff928a7075 100644 --- a/features/bootstrap/JsonApiContext.php +++ b/features/bootstrap/JsonApiContext.php @@ -107,6 +107,26 @@ public function theJsonNodeShouldNotBeAnEmptyString($node) } } + /** + * @Then the JSON node :node should be sorted + * @Then the JSON should be sorted + */ + public function theJsonNodeShouldBeSorted($node = '') + { + $actual = (array) $this->getValueOfNode($node); + + if (!is_array($actual)) { + throw new \Exception(sprintf('The "%s" node value is not an array', $node)); + } + + $expected = $actual; + ksort($expected); + + if ($actual !== $expected) { + throw new ExpectationFailedException(sprintf('The json node "%s" is not sorted by keys', $node)); + } + } + /** * @Given there is a RelatedDummy */ diff --git a/features/hydra/entrypoint.feature b/features/hydra/entrypoint.feature index 7fd1b631137..b4b008583cc 100644 --- a/features/hydra/entrypoint.feature +++ b/features/hydra/entrypoint.feature @@ -8,6 +8,7 @@ Feature: Entrypoint support Then the response status code should be 200 And the response should be in JSON And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the JSON should be sorted And the JSON node "@context" should be equal to "/contexts/Entrypoint" And the JSON node "@id" should be equal to "/" And the JSON node "@type" should be equal to "Entrypoint" diff --git a/src/Hydra/Serializer/EntrypointNormalizer.php b/src/Hydra/Serializer/EntrypointNormalizer.php index d64741593b8..45aa8cfaa46 100644 --- a/src/Hydra/Serializer/EntrypointNormalizer.php +++ b/src/Hydra/Serializer/EntrypointNormalizer.php @@ -65,6 +65,8 @@ public function normalize($object, $format = null, array $context = []) } } + ksort($entrypoint); + return $entrypoint; } diff --git a/tests/Hydra/Serializer/EntrypointNormalizerTest.php b/tests/Hydra/Serializer/EntrypointNormalizerTest.php index 5205965be21..36752e018a1 100644 --- a/tests/Hydra/Serializer/EntrypointNormalizerTest.php +++ b/tests/Hydra/Serializer/EntrypointNormalizerTest.php @@ -21,6 +21,7 @@ use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; use ApiPlatform\Core\Metadata\Resource\ResourceNameCollection; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\FooDummy; use PHPUnit\Framework\TestCase; /** @@ -47,14 +48,16 @@ public function testSupportNormalization() public function testNormalize() { - $collection = new ResourceNameCollection([Dummy::class]); + $collection = new ResourceNameCollection([FooDummy::class, Dummy::class]); $entrypoint = new Entrypoint($collection); $factoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); $factoryProphecy->create(Dummy::class)->willReturn(new ResourceMetadata('Dummy', null, null, null, ['get']))->shouldBeCalled(); + $factoryProphecy->create(FooDummy::class)->willReturn(new ResourceMetadata('FooDummy', null, null, null, ['get']))->shouldBeCalled(); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); $iriConverterProphecy->getIriFromResourceClass(Dummy::class)->willReturn('/api/dummies')->shouldBeCalled(); + $iriConverterProphecy->getIriFromResourceClass(FooDummy::class)->willReturn('/api/foo_dummies')->shouldBeCalled(); $urlGeneratorProphecy = $this->prophesize(UrlGeneratorInterface::class); $urlGeneratorProphecy->generate('api_entrypoint')->willReturn('/api')->shouldBeCalled(); @@ -67,6 +70,7 @@ public function testNormalize() '@id' => '/api', '@type' => 'Entrypoint', 'dummy' => '/api/dummies', + 'fooDummy' => '/api/foo_dummies', ]; $this->assertEquals($expected, $normalizer->normalize($entrypoint, EntrypointNormalizer::FORMAT)); } From d8e624ba242593365773ec8070d92f548f77d575 Mon Sep 17 00:00:00 2001 From: Alan Poulain Date: Mon, 7 Oct 2019 15:24:41 +0200 Subject: [PATCH 006/160] Possibility to add execute options for MongoDB (#3144) --- CHANGELOG.md | 4 + .../MongoDbOdm/CollectionDataProvider.php | 11 +- .../Extension/PaginationExtension.php | 11 +- .../Doctrine/MongoDbOdm/ItemDataProvider.php | 11 +- .../MongoDbOdm/SubresourceDataProvider.php | 19 +- .../Resources/config/doctrine_mongodb_odm.xml | 5 +- .../MongoDbOdm/CollectionDataProviderTest.php | 72 ++++++-- .../Extension/PaginationExtensionTest.php | 162 ++++++++++++------ .../MongoDbOdm/ItemDataProviderTest.php | 72 +++++++- .../SubresourceDataProviderTest.php | 153 +++++++++++++++-- tests/Fixtures/TestBundle/Document/Dummy.php | 5 + 11 files changed, 427 insertions(+), 98 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a1fa17ab58..b3bb0b0e374 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 2.6.0 + +* MongoDB: Possibility to add execute options (aggregate command fields) for a resource, like `allowDiskUse` (#3144) + ## 2.5.0 * Fix BC-break when using short-syntax notation for `access_control` diff --git a/src/Bridge/Doctrine/MongoDbOdm/CollectionDataProvider.php b/src/Bridge/Doctrine/MongoDbOdm/CollectionDataProvider.php index da4dac68ba5..b8820439d8a 100644 --- a/src/Bridge/Doctrine/MongoDbOdm/CollectionDataProvider.php +++ b/src/Bridge/Doctrine/MongoDbOdm/CollectionDataProvider.php @@ -18,6 +18,7 @@ use ApiPlatform\Core\DataProvider\CollectionDataProviderInterface; use ApiPlatform\Core\DataProvider\RestrictedDataProviderInterface; use ApiPlatform\Core\Exception\RuntimeException; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use Doctrine\Common\Persistence\ManagerRegistry; use Doctrine\ODM\MongoDB\DocumentManager; use Doctrine\ODM\MongoDB\Repository\DocumentRepository; @@ -32,14 +33,16 @@ final class CollectionDataProvider implements CollectionDataProviderInterface, RestrictedDataProviderInterface { private $managerRegistry; + private $resourceMetadataFactory; private $collectionExtensions; /** * @param AggregationCollectionExtensionInterface[] $collectionExtensions */ - public function __construct(ManagerRegistry $managerRegistry, iterable $collectionExtensions = []) + public function __construct(ManagerRegistry $managerRegistry, ResourceMetadataFactoryInterface $resourceMetadataFactory, iterable $collectionExtensions = []) { $this->managerRegistry = $managerRegistry; + $this->resourceMetadataFactory = $resourceMetadataFactory; $this->collectionExtensions = $collectionExtensions; } @@ -72,6 +75,10 @@ public function getCollection(string $resourceClass, string $operationName = nul } } - return $aggregationBuilder->hydrate($resourceClass)->execute(); + $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass); + $attribute = $resourceMetadata->getCollectionOperationAttribute($operationName, 'doctrine_mongodb', [], true); + $executeOptions = $attribute['execute_options'] ?? []; + + return $aggregationBuilder->hydrate($resourceClass)->execute($executeOptions); } } diff --git a/src/Bridge/Doctrine/MongoDbOdm/Extension/PaginationExtension.php b/src/Bridge/Doctrine/MongoDbOdm/Extension/PaginationExtension.php index 75215bbeb61..80399ffbc20 100644 --- a/src/Bridge/Doctrine/MongoDbOdm/Extension/PaginationExtension.php +++ b/src/Bridge/Doctrine/MongoDbOdm/Extension/PaginationExtension.php @@ -16,6 +16,7 @@ use ApiPlatform\Core\Bridge\Doctrine\MongoDbOdm\Paginator; use ApiPlatform\Core\DataProvider\Pagination; use ApiPlatform\Core\Exception\RuntimeException; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use Doctrine\Common\Persistence\ManagerRegistry; use Doctrine\ODM\MongoDB\Aggregation\Builder; use Doctrine\ODM\MongoDB\DocumentManager; @@ -33,11 +34,13 @@ final class PaginationExtension implements AggregationResultCollectionExtensionInterface { private $managerRegistry; + private $resourceMetadataFactory; private $pagination; - public function __construct(ManagerRegistry $managerRegistry, Pagination $pagination) + public function __construct(ManagerRegistry $managerRegistry, ResourceMetadataFactoryInterface $resourceMetadataFactory, Pagination $pagination) { $this->managerRegistry = $managerRegistry; + $this->resourceMetadataFactory = $resourceMetadataFactory; $this->pagination = $pagination; } @@ -113,7 +116,11 @@ public function getResult(Builder $aggregationBuilder, string $resourceClass, st throw new RuntimeException(sprintf('The manager for "%s" must be an instance of "%s".', $resourceClass, DocumentManager::class)); } - return new Paginator($aggregationBuilder->execute(), $manager->getUnitOfWork(), $resourceClass, $aggregationBuilder->getPipeline()); + $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass); + $attribute = $resourceMetadata->getCollectionOperationAttribute($operationName, 'doctrine_mongodb', [], true); + $executeOptions = $attribute['execute_options'] ?? []; + + return new Paginator($aggregationBuilder->execute($executeOptions), $manager->getUnitOfWork(), $resourceClass, $aggregationBuilder->getPipeline()); } private function addCountToContext(Builder $aggregationBuilder, array $context): array diff --git a/src/Bridge/Doctrine/MongoDbOdm/ItemDataProvider.php b/src/Bridge/Doctrine/MongoDbOdm/ItemDataProvider.php index 9dcc53f35e1..12db2dda163 100644 --- a/src/Bridge/Doctrine/MongoDbOdm/ItemDataProvider.php +++ b/src/Bridge/Doctrine/MongoDbOdm/ItemDataProvider.php @@ -22,6 +22,7 @@ use ApiPlatform\Core\Identifier\IdentifierConverterInterface; use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use Doctrine\Common\Persistence\ManagerRegistry; use Doctrine\ODM\MongoDB\DocumentManager; use Doctrine\ODM\MongoDB\Repository\DocumentRepository; @@ -38,14 +39,16 @@ final class ItemDataProvider implements DenormalizedIdentifiersAwareItemDataProv use IdentifierManagerTrait; private $managerRegistry; + private $resourceMetadataFactory; private $itemExtensions; /** * @param AggregationItemExtensionInterface[] $itemExtensions */ - public function __construct(ManagerRegistry $managerRegistry, PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, iterable $itemExtensions = []) + public function __construct(ManagerRegistry $managerRegistry, ResourceMetadataFactoryInterface $resourceMetadataFactory, PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, iterable $itemExtensions = []) { $this->managerRegistry = $managerRegistry; + $this->resourceMetadataFactory = $resourceMetadataFactory; $this->propertyNameCollectionFactory = $propertyNameCollectionFactory; $this->propertyMetadataFactory = $propertyMetadataFactory; $this->itemExtensions = $itemExtensions; @@ -95,6 +98,10 @@ public function getItem(string $resourceClass, $id, string $operationName = null } } - return $aggregationBuilder->hydrate($resourceClass)->execute()->current() ?: null; + $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass); + $attribute = $resourceMetadata->getItemOperationAttribute($operationName, 'doctrine_mongodb', [], true); + $executeOptions = $attribute['execute_options'] ?? []; + + return $aggregationBuilder->hydrate($resourceClass)->execute($executeOptions)->current() ?: null; } } diff --git a/src/Bridge/Doctrine/MongoDbOdm/SubresourceDataProvider.php b/src/Bridge/Doctrine/MongoDbOdm/SubresourceDataProvider.php index 2c490deb456..beceaf0a22d 100644 --- a/src/Bridge/Doctrine/MongoDbOdm/SubresourceDataProvider.php +++ b/src/Bridge/Doctrine/MongoDbOdm/SubresourceDataProvider.php @@ -24,6 +24,7 @@ use ApiPlatform\Core\Identifier\IdentifierConverterInterface; use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use Doctrine\Common\Persistence\ManagerRegistry; use Doctrine\ODM\MongoDB\Aggregation\Builder; use Doctrine\ODM\MongoDB\DocumentManager; @@ -43,6 +44,7 @@ final class SubresourceDataProvider implements SubresourceDataProviderInterface use IdentifierManagerTrait; private $managerRegistry; + private $resourceMetadataFactory; private $collectionExtensions; private $itemExtensions; @@ -50,9 +52,10 @@ final class SubresourceDataProvider implements SubresourceDataProviderInterface * @param AggregationCollectionExtensionInterface[] $collectionExtensions * @param AggregationItemExtensionInterface[] $itemExtensions */ - public function __construct(ManagerRegistry $managerRegistry, PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, iterable $collectionExtensions = [], iterable $itemExtensions = []) + public function __construct(ManagerRegistry $managerRegistry, ResourceMetadataFactoryInterface $resourceMetadataFactory, PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, iterable $collectionExtensions = [], iterable $itemExtensions = []) { $this->managerRegistry = $managerRegistry; + $this->resourceMetadataFactory = $resourceMetadataFactory; $this->propertyNameCollectionFactory = $propertyNameCollectionFactory; $this->propertyMetadataFactory = $propertyMetadataFactory; $this->collectionExtensions = $collectionExtensions; @@ -80,7 +83,11 @@ public function getSubresource(string $resourceClass, array $identifiers, array throw new ResourceClassNotSupportedException('The given resource class is not a subresource.'); } - $aggregationBuilder = $this->buildAggregation($identifiers, $context, $repository->createAggregationBuilder(), \count($context['identifiers'])); + $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass); + $attribute = $resourceMetadata->getSubresourceOperationAttribute($operationName, 'doctrine_mongodb', [], true); + $executeOptions = $attribute['execute_options'] ?? []; + + $aggregationBuilder = $this->buildAggregation($identifiers, $context, $executeOptions, $repository->createAggregationBuilder(), \count($context['identifiers'])); if (true === $context['collection']) { foreach ($this->collectionExtensions as $extension) { @@ -98,7 +105,7 @@ public function getSubresource(string $resourceClass, array $identifiers, array } } - $iterator = $aggregationBuilder->hydrate($resourceClass)->execute(); + $iterator = $aggregationBuilder->hydrate($resourceClass)->execute($executeOptions); return $context['collection'] ? $iterator->toArray() : ($iterator->current() ?: null); } @@ -106,7 +113,7 @@ public function getSubresource(string $resourceClass, array $identifiers, array /** * @throws RuntimeException */ - private function buildAggregation(array $identifiers, array $context, Builder $previousAggregationBuilder, int $remainingIdentifiers, Builder $topAggregationBuilder = null): Builder + private function buildAggregation(array $identifiers, array $context, array $executeOptions, Builder $previousAggregationBuilder, int $remainingIdentifiers, Builder $topAggregationBuilder = null): Builder { if ($remainingIdentifiers <= 0) { return $previousAggregationBuilder; @@ -154,9 +161,9 @@ private function buildAggregation(array $identifiers, array $context, Builder $p } // Recurse aggregations - $aggregation = $this->buildAggregation($identifiers, $context, $aggregation, --$remainingIdentifiers, $topAggregationBuilder); + $aggregation = $this->buildAggregation($identifiers, $context, $executeOptions, $aggregation, --$remainingIdentifiers, $topAggregationBuilder); - $results = $aggregation->execute()->toArray(); + $results = $aggregation->execute($executeOptions)->toArray(); $in = array_reduce($results, function ($in, $result) use ($previousAssociationProperty) { return $in + array_map(function ($result) { return $result['_id']; diff --git a/src/Bridge/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.xml b/src/Bridge/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.xml index fa300e4a700..d129fd7019b 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.xml @@ -24,11 +24,13 @@ + + @@ -36,6 +38,7 @@ + @@ -46,7 +49,6 @@ parent="api_platform.doctrine_mongodb.odm.collection_data_provider" class="ApiPlatform\Core\Bridge\Doctrine\MongoDbOdm\CollectionDataProvider"> - + diff --git a/tests/Bridge/Doctrine/MongoDbOdm/CollectionDataProviderTest.php b/tests/Bridge/Doctrine/MongoDbOdm/CollectionDataProviderTest.php index 001e4d997a8..2b972521c1a 100644 --- a/tests/Bridge/Doctrine/MongoDbOdm/CollectionDataProviderTest.php +++ b/tests/Bridge/Doctrine/MongoDbOdm/CollectionDataProviderTest.php @@ -17,6 +17,8 @@ use ApiPlatform\Core\Bridge\Doctrine\MongoDbOdm\Extension\AggregationCollectionExtensionInterface; use ApiPlatform\Core\Bridge\Doctrine\MongoDbOdm\Extension\AggregationResultCollectionExtensionInterface; use ApiPlatform\Core\Exception\RuntimeException; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\Dummy; use Doctrine\Common\Persistence\ManagerRegistry; use Doctrine\Common\Persistence\ObjectRepository; @@ -33,13 +35,27 @@ */ class CollectionDataProviderTest extends TestCase { + private $managerRegistryProphecy; + private $resourceMetadataFactoryProphecy; + + /** + * {@inheritdoc} + */ + protected function setUp(): void + { + parent::setUp(); + + $this->managerRegistryProphecy = $this->prophesize(ManagerRegistry::class); + $this->resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + } + public function testGetCollection() { $iterator = $this->prophesize(Iterator::class)->reveal(); $aggregationBuilderProphecy = $this->prophesize(Builder::class); $aggregationBuilderProphecy->hydrate(Dummy::class)->willReturn($aggregationBuilderProphecy)->shouldBeCalled(); - $aggregationBuilderProphecy->execute()->willReturn($iterator)->shouldBeCalled(); + $aggregationBuilderProphecy->execute([])->willReturn($iterator)->shouldBeCalled(); $aggregationBuilder = $aggregationBuilderProphecy->reveal(); $repositoryProphecy = $this->prophesize(DocumentRepository::class); @@ -48,13 +64,46 @@ public function testGetCollection() $managerProphecy = $this->prophesize(DocumentManager::class); $managerProphecy->getRepository(Dummy::class)->willReturn($repositoryProphecy->reveal())->shouldBeCalled(); - $managerRegistryProphecy = $this->prophesize(ManagerRegistry::class); - $managerRegistryProphecy->getManagerForClass(Dummy::class)->willReturn($managerProphecy->reveal())->shouldBeCalled(); + $this->managerRegistryProphecy->getManagerForClass(Dummy::class)->willReturn($managerProphecy->reveal())->shouldBeCalled(); + + $this->resourceMetadataFactoryProphecy->create(Dummy::class)->willReturn(new ResourceMetadata()); + + $extensionProphecy = $this->prophesize(AggregationCollectionExtensionInterface::class); + $extensionProphecy->applyToCollection($aggregationBuilder, Dummy::class, 'foo', [])->shouldBeCalled(); + + $dataProvider = new CollectionDataProvider($this->managerRegistryProphecy->reveal(), $this->resourceMetadataFactoryProphecy->reveal(), [$extensionProphecy->reveal()]); + $this->assertEquals($iterator, $dataProvider->getCollection(Dummy::class, 'foo')); + } + + public function testGetCollectionWithExecuteOptions() + { + $iterator = $this->prophesize(Iterator::class)->reveal(); + + $aggregationBuilderProphecy = $this->prophesize(Builder::class); + $aggregationBuilderProphecy->hydrate(Dummy::class)->willReturn($aggregationBuilderProphecy)->shouldBeCalled(); + $aggregationBuilderProphecy->execute(['allowDiskUse' => true])->willReturn($iterator)->shouldBeCalled(); + $aggregationBuilder = $aggregationBuilderProphecy->reveal(); + + $repositoryProphecy = $this->prophesize(DocumentRepository::class); + $repositoryProphecy->createAggregationBuilder()->willReturn($aggregationBuilder)->shouldBeCalled(); + + $managerProphecy = $this->prophesize(DocumentManager::class); + $managerProphecy->getRepository(Dummy::class)->willReturn($repositoryProphecy->reveal())->shouldBeCalled(); + + $this->managerRegistryProphecy->getManagerForClass(Dummy::class)->willReturn($managerProphecy->reveal())->shouldBeCalled(); + + $this->resourceMetadataFactoryProphecy->create(Dummy::class)->willReturn(new ResourceMetadata( + 'Dummy', + null, + null, + null, + ['foo' => ['doctrine_mongodb' => ['execute_options' => ['allowDiskUse' => true]]]] + )); $extensionProphecy = $this->prophesize(AggregationCollectionExtensionInterface::class); $extensionProphecy->applyToCollection($aggregationBuilder, Dummy::class, 'foo', [])->shouldBeCalled(); - $dataProvider = new CollectionDataProvider($managerRegistryProphecy->reveal(), [$extensionProphecy->reveal()]); + $dataProvider = new CollectionDataProvider($this->managerRegistryProphecy->reveal(), $this->resourceMetadataFactoryProphecy->reveal(), [$extensionProphecy->reveal()]); $this->assertEquals($iterator, $dataProvider->getCollection(Dummy::class, 'foo')); } @@ -69,15 +118,14 @@ public function testAggregationResultExtension() $managerProphecy = $this->prophesize(DocumentManager::class); $managerProphecy->getRepository(Dummy::class)->willReturn($repositoryProphecy->reveal())->shouldBeCalled(); - $managerRegistryProphecy = $this->prophesize(ManagerRegistry::class); - $managerRegistryProphecy->getManagerForClass(Dummy::class)->willReturn($managerProphecy->reveal())->shouldBeCalled(); + $this->managerRegistryProphecy->getManagerForClass(Dummy::class)->willReturn($managerProphecy->reveal())->shouldBeCalled(); $extensionProphecy = $this->prophesize(AggregationResultCollectionExtensionInterface::class); $extensionProphecy->applyToCollection($aggregationBuilder, Dummy::class, 'foo', [])->shouldBeCalled(); $extensionProphecy->supportsResult(Dummy::class, 'foo', [])->willReturn(true)->shouldBeCalled(); $extensionProphecy->getResult($aggregationBuilder, Dummy::class, 'foo', [])->willReturn([])->shouldBeCalled(); - $dataProvider = new CollectionDataProvider($managerRegistryProphecy->reveal(), [$extensionProphecy->reveal()]); + $dataProvider = new CollectionDataProvider($this->managerRegistryProphecy->reveal(), $this->resourceMetadataFactoryProphecy->reveal(), [$extensionProphecy->reveal()]); $this->assertEquals([], $dataProvider->getCollection(Dummy::class, 'foo')); } @@ -91,21 +139,19 @@ public function testCannotCreateAggregationBuilder() $managerProphecy = $this->prophesize(DocumentManager::class); $managerProphecy->getRepository(Dummy::class)->willReturn($repositoryProphecy->reveal())->shouldBeCalled(); - $managerRegistryProphecy = $this->prophesize(ManagerRegistry::class); - $managerRegistryProphecy->getManagerForClass(Dummy::class)->willReturn($managerProphecy->reveal())->shouldBeCalled(); + $this->managerRegistryProphecy->getManagerForClass(Dummy::class)->willReturn($managerProphecy->reveal())->shouldBeCalled(); - $dataProvider = new CollectionDataProvider($managerRegistryProphecy->reveal()); + $dataProvider = new CollectionDataProvider($this->managerRegistryProphecy->reveal(), $this->resourceMetadataFactoryProphecy->reveal()); $this->assertEquals([], $dataProvider->getCollection(Dummy::class, 'foo')); } public function testUnsupportedClass() { - $managerRegistryProphecy = $this->prophesize(ManagerRegistry::class); - $managerRegistryProphecy->getManagerForClass(Dummy::class)->willReturn(null)->shouldBeCalled(); + $this->managerRegistryProphecy->getManagerForClass(Dummy::class)->willReturn(null)->shouldBeCalled(); $extensionProphecy = $this->prophesize(AggregationResultCollectionExtensionInterface::class); - $dataProvider = new CollectionDataProvider($managerRegistryProphecy->reveal(), [$extensionProphecy->reveal()]); + $dataProvider = new CollectionDataProvider($this->managerRegistryProphecy->reveal(), $this->resourceMetadataFactoryProphecy->reveal(), [$extensionProphecy->reveal()]); $this->assertFalse($dataProvider->supports(Dummy::class, 'foo')); } } diff --git a/tests/Bridge/Doctrine/MongoDbOdm/Extension/PaginationExtensionTest.php b/tests/Bridge/Doctrine/MongoDbOdm/Extension/PaginationExtensionTest.php index f6b4f383628..9532392f42a 100644 --- a/tests/Bridge/Doctrine/MongoDbOdm/Extension/PaginationExtensionTest.php +++ b/tests/Bridge/Doctrine/MongoDbOdm/Extension/PaginationExtensionTest.php @@ -42,24 +42,28 @@ class PaginationExtensionTest extends TestCase { private $managerRegistryProphecy; + private $resourceMetadataFactoryProphecy; + /** + * {@inheritdoc} + */ protected function setUp(): void { parent::setUp(); $this->managerRegistryProphecy = $this->prophesize(ManagerRegistry::class); + $this->resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); } public function testApplyToCollection() { - $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); $attributes = [ 'pagination_enabled' => true, 'pagination_client_enabled' => true, 'pagination_items_per_page' => 40, ]; - $resourceMetadataFactoryProphecy->create('Foo')->willReturn(new ResourceMetadata(null, null, null, [], [], $attributes)); - $resourceMetadataFactory = $resourceMetadataFactoryProphecy->reveal(); + $this->resourceMetadataFactoryProphecy->create('Foo')->willReturn(new ResourceMetadata(null, null, null, [], [], $attributes)); + $resourceMetadataFactory = $this->resourceMetadataFactoryProphecy->reveal(); $pagination = new Pagination($resourceMetadataFactory, [ 'page_parameter_name' => '_page', @@ -71,6 +75,7 @@ public function testApplyToCollection() $extension = new PaginationExtension( $this->managerRegistryProphecy->reveal(), + $resourceMetadataFactory, $pagination ); $extension->applyToCollection($aggregationBuilderProphecy->reveal(), 'Foo', 'op', $context); @@ -78,14 +83,13 @@ public function testApplyToCollection() public function testApplyToCollectionWithItemPerPageZero() { - $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); $attributes = [ 'pagination_enabled' => true, 'pagination_client_enabled' => true, 'pagination_items_per_page' => 0, ]; - $resourceMetadataFactoryProphecy->create('Foo')->willReturn(new ResourceMetadata(null, null, null, [], [], $attributes)); - $resourceMetadataFactory = $resourceMetadataFactoryProphecy->reveal(); + $this->resourceMetadataFactoryProphecy->create('Foo')->willReturn(new ResourceMetadata(null, null, null, [], [], $attributes)); + $resourceMetadataFactory = $this->resourceMetadataFactoryProphecy->reveal(); $pagination = new Pagination($resourceMetadataFactory, [ 'items_per_page' => 0, @@ -98,6 +102,7 @@ public function testApplyToCollectionWithItemPerPageZero() $extension = new PaginationExtension( $this->managerRegistryProphecy->reveal(), + $resourceMetadataFactory, $pagination ); $extension->applyToCollection($aggregationBuilderProphecy->reveal(), 'Foo', 'op', $context); @@ -108,14 +113,13 @@ public function testApplyToCollectionWithItemPerPageZeroAndPage2() $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Page should not be greater than 1 if limit is equal to 0'); - $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); $attributes = [ 'pagination_enabled' => true, 'pagination_client_enabled' => true, 'pagination_items_per_page' => 0, ]; - $resourceMetadataFactoryProphecy->create('Foo')->willReturn(new ResourceMetadata(null, null, null, [], [], $attributes)); - $resourceMetadataFactory = $resourceMetadataFactoryProphecy->reveal(); + $this->resourceMetadataFactoryProphecy->create('Foo')->willReturn(new ResourceMetadata(null, null, null, [], [], $attributes)); + $resourceMetadataFactory = $this->resourceMetadataFactoryProphecy->reveal(); $pagination = new Pagination($resourceMetadataFactory, [ 'items_per_page' => 0, @@ -129,6 +133,7 @@ public function testApplyToCollectionWithItemPerPageZeroAndPage2() $extension = new PaginationExtension( $this->prophesize(ManagerRegistry::class)->reveal(), + $resourceMetadataFactory, $pagination ); $extension->applyToCollection($aggregationBuilderProphecy->reveal(), 'Foo', 'op', $context); @@ -139,14 +144,13 @@ public function testApplyToCollectionWithItemPerPageLessThan0() $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Limit should not be less than 0'); - $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); $attributes = [ 'pagination_enabled' => true, 'pagination_client_enabled' => true, 'pagination_items_per_page' => -20, ]; - $resourceMetadataFactoryProphecy->create('Foo')->willReturn(new ResourceMetadata(null, null, null, [], [], $attributes)); - $resourceMetadataFactory = $resourceMetadataFactoryProphecy->reveal(); + $this->resourceMetadataFactoryProphecy->create('Foo')->willReturn(new ResourceMetadata(null, null, null, [], [], $attributes)); + $resourceMetadataFactory = $this->resourceMetadataFactoryProphecy->reveal(); $pagination = new Pagination($resourceMetadataFactory, [ 'items_per_page' => -20, @@ -160,6 +164,7 @@ public function testApplyToCollectionWithItemPerPageLessThan0() $extension = new PaginationExtension( $this->managerRegistryProphecy->reveal(), + $resourceMetadataFactory, $pagination ); $extension->applyToCollection($aggregationBuilderProphecy->reveal(), 'Foo', 'op', $context); @@ -167,14 +172,13 @@ public function testApplyToCollectionWithItemPerPageLessThan0() public function testApplyToCollectionWithItemPerPageTooHigh() { - $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); $attributes = [ 'pagination_enabled' => true, 'pagination_client_enabled' => true, 'pagination_client_items_per_page' => true, ]; - $resourceMetadataFactoryProphecy->create('Foo')->willReturn(new ResourceMetadata(null, null, null, [], [], $attributes)); - $resourceMetadataFactory = $resourceMetadataFactoryProphecy->reveal(); + $this->resourceMetadataFactoryProphecy->create('Foo')->willReturn(new ResourceMetadata(null, null, null, [], [], $attributes)); + $resourceMetadataFactory = $this->resourceMetadataFactoryProphecy->reveal(); $pagination = new Pagination($resourceMetadataFactory, [ 'page_parameter_name' => '_page', @@ -187,6 +191,7 @@ public function testApplyToCollectionWithItemPerPageTooHigh() $extension = new PaginationExtension( $this->managerRegistryProphecy->reveal(), + $resourceMetadataFactory, $pagination ); $extension->applyToCollection($aggregationBuilderProphecy->reveal(), 'Foo', 'op', $context); @@ -194,14 +199,13 @@ public function testApplyToCollectionWithItemPerPageTooHigh() public function testApplyToCollectionWithGraphql() { - $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); $attributes = [ 'pagination_enabled' => true, 'pagination_client_enabled' => true, 'pagination_client_items_per_page' => 20, ]; - $resourceMetadataFactoryProphecy->create('Foo')->willReturn(new ResourceMetadata(null, null, null, [], [], $attributes)); - $resourceMetadataFactory = $resourceMetadataFactoryProphecy->reveal(); + $this->resourceMetadataFactoryProphecy->create('Foo')->willReturn(new ResourceMetadata(null, null, null, [], [], $attributes)); + $resourceMetadataFactory = $this->resourceMetadataFactoryProphecy->reveal(); $pagination = new Pagination($resourceMetadataFactory); @@ -211,6 +215,7 @@ public function testApplyToCollectionWithGraphql() $extension = new PaginationExtension( $this->managerRegistryProphecy->reveal(), + $resourceMetadataFactory, $pagination ); $extension->applyToCollection($aggregationBuilderProphecy->reveal(), 'Foo', 'op', $context); @@ -218,14 +223,13 @@ public function testApplyToCollectionWithGraphql() public function testApplyToCollectionWithGraphqlAndCountContext() { - $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); $attributes = [ 'pagination_enabled' => true, 'pagination_client_enabled' => true, 'pagination_client_items_per_page' => 20, ]; - $resourceMetadataFactoryProphecy->create('Foo')->willReturn(new ResourceMetadata(null, null, null, [], [], $attributes)); - $resourceMetadataFactory = $resourceMetadataFactoryProphecy->reveal(); + $this->resourceMetadataFactoryProphecy->create('Foo')->willReturn(new ResourceMetadata(null, null, null, [], [], $attributes)); + $resourceMetadataFactory = $this->resourceMetadataFactoryProphecy->reveal(); $pagination = new Pagination($resourceMetadataFactory); @@ -244,6 +248,7 @@ public function testApplyToCollectionWithGraphqlAndCountContext() $extension = new PaginationExtension( $this->managerRegistryProphecy->reveal(), + $resourceMetadataFactory, $pagination ); $extension->applyToCollection($aggregationBuilderProphecy->reveal(), 'Foo', 'op', $context); @@ -251,9 +256,8 @@ public function testApplyToCollectionWithGraphqlAndCountContext() public function testApplyToCollectionNoFilters() { - $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); - $resourceMetadataFactoryProphecy->create('Foo')->willReturn(new ResourceMetadata(null, null, null, [], [])); - $resourceMetadataFactory = $resourceMetadataFactoryProphecy->reveal(); + $this->resourceMetadataFactoryProphecy->create('Foo')->willReturn(new ResourceMetadata(null, null, null, [], [])); + $resourceMetadataFactory = $this->resourceMetadataFactoryProphecy->reveal(); $pagination = new Pagination($resourceMetadataFactory); @@ -263,6 +267,7 @@ public function testApplyToCollectionNoFilters() $extension = new PaginationExtension( $this->managerRegistryProphecy->reveal(), + $resourceMetadataFactory, $pagination ); $extension->applyToCollection($aggregationBuilderProphecy->reveal(), 'Foo', 'op', $context); @@ -270,9 +275,8 @@ public function testApplyToCollectionNoFilters() public function testApplyToCollectionPaginationDisabled() { - $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); - $resourceMetadataFactoryProphecy->create('Foo')->willReturn(new ResourceMetadata(null, null, null, [], [])); - $resourceMetadataFactory = $resourceMetadataFactoryProphecy->reveal(); + $this->resourceMetadataFactoryProphecy->create('Foo')->willReturn(new ResourceMetadata(null, null, null, [], [])); + $resourceMetadataFactory = $this->resourceMetadataFactoryProphecy->reveal(); $pagination = new Pagination($resourceMetadataFactory, [ 'enabled' => false, @@ -285,6 +289,7 @@ public function testApplyToCollectionPaginationDisabled() $extension = new PaginationExtension( $this->managerRegistryProphecy->reveal(), + $resourceMetadataFactory, $pagination ); $extension->applyToCollection($aggregationBuilderProphecy->reveal(), 'Foo', 'op', $context); @@ -292,9 +297,8 @@ public function testApplyToCollectionPaginationDisabled() public function testApplyToCollectionGraphQlPaginationDisabled() { - $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); - $resourceMetadataFactoryProphecy->create('Foo')->willReturn(new ResourceMetadata(null, null, null, [], [])); - $resourceMetadataFactory = $resourceMetadataFactoryProphecy->reveal(); + $this->resourceMetadataFactoryProphecy->create('Foo')->willReturn(new ResourceMetadata(null, null, null, [], [])); + $resourceMetadataFactory = $this->resourceMetadataFactoryProphecy->reveal(); $pagination = new Pagination($resourceMetadataFactory, [], [ 'enabled' => false, @@ -307,6 +311,7 @@ public function testApplyToCollectionGraphQlPaginationDisabled() $extension = new PaginationExtension( $this->managerRegistryProphecy->reveal(), + $resourceMetadataFactory, $pagination ); $extension->applyToCollection($aggregationBuilderProphecy->reveal(), 'Foo', 'op', $context); @@ -314,14 +319,13 @@ public function testApplyToCollectionGraphQlPaginationDisabled() public function testApplyToCollectionWithMaximumItemsPerPage() { - $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); $attributes = [ 'pagination_enabled' => true, 'pagination_client_enabled' => true, 'pagination_maximum_items_per_page' => 80, ]; - $resourceMetadataFactoryProphecy->create('Foo')->willReturn(new ResourceMetadata(null, null, null, [], [], $attributes)); - $resourceMetadataFactory = $resourceMetadataFactoryProphecy->reveal(); + $this->resourceMetadataFactoryProphecy->create('Foo')->willReturn(new ResourceMetadata(null, null, null, [], [], $attributes)); + $resourceMetadataFactory = $this->resourceMetadataFactoryProphecy->reveal(); $pagination = new Pagination($resourceMetadataFactory, [ 'client_enabled' => true, @@ -335,6 +339,7 @@ public function testApplyToCollectionWithMaximumItemsPerPage() $extension = new PaginationExtension( $this->managerRegistryProphecy->reveal(), + $resourceMetadataFactory, $pagination ); $extension->applyToCollection($aggregationBuilderProphecy->reveal(), 'Foo', 'op', $context); @@ -342,14 +347,14 @@ public function testApplyToCollectionWithMaximumItemsPerPage() public function testSupportsResult() { - $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); - $resourceMetadataFactoryProphecy->create('Foo')->willReturn(new ResourceMetadata(null, null, null, [], [])); - $resourceMetadataFactory = $resourceMetadataFactoryProphecy->reveal(); + $this->resourceMetadataFactoryProphecy->create('Foo')->willReturn(new ResourceMetadata(null, null, null, [], [])); + $resourceMetadataFactory = $this->resourceMetadataFactoryProphecy->reveal(); $pagination = new Pagination($resourceMetadataFactory); $extension = new PaginationExtension( $this->managerRegistryProphecy->reveal(), + $resourceMetadataFactory, $pagination ); $this->assertTrue($extension->supportsResult('Foo', 'op')); @@ -357,9 +362,8 @@ public function testSupportsResult() public function testSupportsResultClientNotAllowedToPaginate() { - $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); - $resourceMetadataFactoryProphecy->create('Foo')->willReturn(new ResourceMetadata(null, null, null, [], [])); - $resourceMetadataFactory = $resourceMetadataFactoryProphecy->reveal(); + $this->resourceMetadataFactoryProphecy->create('Foo')->willReturn(new ResourceMetadata(null, null, null, [], [])); + $resourceMetadataFactory = $this->resourceMetadataFactoryProphecy->reveal(); $pagination = new Pagination($resourceMetadataFactory, [ 'enabled' => false, @@ -368,6 +372,7 @@ public function testSupportsResultClientNotAllowedToPaginate() $extension = new PaginationExtension( $this->managerRegistryProphecy->reveal(), + $resourceMetadataFactory, $pagination ); $this->assertFalse($extension->supportsResult('Foo', 'op', ['filters' => ['pagination' => true]])); @@ -375,9 +380,8 @@ public function testSupportsResultClientNotAllowedToPaginate() public function testSupportsResultPaginationDisabled() { - $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); - $resourceMetadataFactoryProphecy->create('Foo')->willReturn(new ResourceMetadata(null, null, null, [], [])); - $resourceMetadataFactory = $resourceMetadataFactoryProphecy->reveal(); + $this->resourceMetadataFactoryProphecy->create('Foo')->willReturn(new ResourceMetadata(null, null, null, [], [])); + $resourceMetadataFactory = $this->resourceMetadataFactoryProphecy->reveal(); $pagination = new Pagination($resourceMetadataFactory, [ 'enabled' => false, @@ -385,6 +389,7 @@ public function testSupportsResultPaginationDisabled() $extension = new PaginationExtension( $this->managerRegistryProphecy->reveal(), + $resourceMetadataFactory, $pagination ); $this->assertFalse($extension->supportsResult('Foo', 'op', ['filters' => ['enabled' => false]])); @@ -392,9 +397,8 @@ public function testSupportsResultPaginationDisabled() public function testSupportsResultGraphQlPaginationDisabled() { - $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); - $resourceMetadataFactoryProphecy->create('Foo')->willReturn(new ResourceMetadata(null, null, null, [], [])); - $resourceMetadataFactory = $resourceMetadataFactoryProphecy->reveal(); + $this->resourceMetadataFactoryProphecy->create('Foo')->willReturn(new ResourceMetadata(null, null, null, [], [])); + $resourceMetadataFactory = $this->resourceMetadataFactoryProphecy->reveal(); $pagination = new Pagination($resourceMetadataFactory, [], [ 'enabled' => false, @@ -402,6 +406,7 @@ public function testSupportsResultGraphQlPaginationDisabled() $extension = new PaginationExtension( $this->managerRegistryProphecy->reveal(), + $resourceMetadataFactory, $pagination ); $this->assertFalse($extension->supportsResult('Foo', 'op', ['filters' => ['enabled' => false], 'graphql_operation_name' => 'op'])); @@ -409,8 +414,7 @@ public function testSupportsResultGraphQlPaginationDisabled() public function testGetResult() { - $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); - $resourceMetadataFactory = $resourceMetadataFactoryProphecy->reveal(); + $resourceMetadataFactory = $this->resourceMetadataFactoryProphecy->reveal(); $pagination = new Pagination($resourceMetadataFactory); @@ -420,6 +424,8 @@ public function testGetResult() $this->managerRegistryProphecy->getManagerForClass(Dummy::class)->willReturn($documentManager); + $this->resourceMetadataFactoryProphecy->create(Dummy::class)->willReturn(new ResourceMetadata()); + $iteratorProphecy = $this->prophesize(Iterator::class); $iteratorProphecy->toArray()->willReturn([ [ @@ -432,7 +438,7 @@ public function testGetResult() ]); $aggregationBuilderProphecy = $this->prophesize(Builder::class); - $aggregationBuilderProphecy->execute()->willReturn($iteratorProphecy->reveal()); + $aggregationBuilderProphecy->execute([])->willReturn($iteratorProphecy->reveal()); $aggregationBuilderProphecy->getPipeline()->willReturn([ [ '$facet' => [ @@ -449,6 +455,7 @@ public function testGetResult() $paginationExtension = new PaginationExtension( $this->managerRegistryProphecy->reveal(), + $resourceMetadataFactory, $pagination ); @@ -458,6 +465,65 @@ public function testGetResult() $this->assertInstanceOf(PaginatorInterface::class, $result); } + public function testGetResultWithExecuteOptions() + { + $resourceMetadataFactory = $this->resourceMetadataFactoryProphecy->reveal(); + + $pagination = new Pagination($resourceMetadataFactory); + + $fixturesPath = \dirname((string) (new \ReflectionClass(Dummy::class))->getFileName()); + $config = DoctrineMongoDbOdmSetup::createAnnotationMetadataConfiguration([$fixturesPath], true); + $documentManager = DocumentManager::create(null, $config); + + $this->managerRegistryProphecy->getManagerForClass(Dummy::class)->willReturn($documentManager); + + $this->resourceMetadataFactoryProphecy->create(Dummy::class)->willReturn(new ResourceMetadata( + 'Dummy', + null, + null, + null, + ['foo' => ['doctrine_mongodb' => ['execute_options' => ['allowDiskUse' => true]]]] + )); + + $iteratorProphecy = $this->prophesize(Iterator::class); + $iteratorProphecy->toArray()->willReturn([ + [ + 'count' => [ + [ + 'count' => 9, + ], + ], + ], + ]); + + $aggregationBuilderProphecy = $this->prophesize(Builder::class); + $aggregationBuilderProphecy->execute(['allowDiskUse' => true])->willReturn($iteratorProphecy->reveal()); + $aggregationBuilderProphecy->getPipeline()->willReturn([ + [ + '$facet' => [ + 'results' => [ + ['$skip' => 3], + ['$limit' => 6], + ], + 'count' => [ + ['$count' => 'count'], + ], + ], + ], + ]); + + $paginationExtension = new PaginationExtension( + $this->managerRegistryProphecy->reveal(), + $resourceMetadataFactory, + $pagination + ); + + $result = $paginationExtension->getResult($aggregationBuilderProphecy->reveal(), Dummy::class, 'foo'); + + $this->assertInstanceOf(PartialPaginatorInterface::class, $result); + $this->assertInstanceOf(PaginatorInterface::class, $result); + } + private function mockAggregationBuilder($expectedOffset, $expectedLimit) { $skipProphecy = $this->prophesize(Skip::class); diff --git a/tests/Bridge/Doctrine/MongoDbOdm/ItemDataProviderTest.php b/tests/Bridge/Doctrine/MongoDbOdm/ItemDataProviderTest.php index 2f5cee9f086..bba7ade6290 100644 --- a/tests/Bridge/Doctrine/MongoDbOdm/ItemDataProviderTest.php +++ b/tests/Bridge/Doctrine/MongoDbOdm/ItemDataProviderTest.php @@ -23,6 +23,8 @@ use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Core\Metadata\Property\PropertyMetadata; use ApiPlatform\Core\Metadata\Property\PropertyNameCollection; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\Dummy; use Doctrine\Common\Persistence\ManagerRegistry; use Doctrine\Common\Persistence\ObjectManager; @@ -42,6 +44,18 @@ */ class ItemDataProviderTest extends TestCase { + private $resourceMetadataFactoryProphecy; + + /** + * {@inheritdoc} + */ + protected function setUp(): void + { + parent::setUp(); + + $this->resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + } + public function testGetItemSingleIdentifier() { $context = ['foo' => 'bar', 'fetch_data' => true, IdentifierConverterInterface::HAS_IDENTIFIER_CONVERTER => true]; @@ -57,7 +71,40 @@ public function testGetItemSingleIdentifier() $aggregationBuilderProphecy = $this->prophesize(Builder::class); $aggregationBuilderProphecy->match()->willReturn($matchProphecy->reveal())->shouldBeCalled(); $aggregationBuilderProphecy->hydrate(Dummy::class)->willReturn($aggregationBuilderProphecy)->shouldBeCalled(); - $aggregationBuilderProphecy->execute()->willReturn($iterator->reveal())->shouldBeCalled(); + $aggregationBuilderProphecy->execute([])->willReturn($iterator->reveal())->shouldBeCalled(); + $aggregationBuilder = $aggregationBuilderProphecy->reveal(); + + [$propertyNameCollectionFactory, $propertyMetadataFactory] = $this->getMetadataFactories(Dummy::class, [ + 'id', + ]); + $managerRegistry = $this->getManagerRegistry(Dummy::class, $aggregationBuilder); + + $this->resourceMetadataFactoryProphecy->create(Dummy::class)->willReturn(new ResourceMetadata()); + + $extensionProphecy = $this->prophesize(AggregationItemExtensionInterface::class); + $extensionProphecy->applyToItem($aggregationBuilder, Dummy::class, ['id' => 1], 'foo', $context)->shouldBeCalled(); + + $dataProvider = new ItemDataProvider($managerRegistry, $this->resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory, [$extensionProphecy->reveal()]); + + $this->assertEquals($result, $dataProvider->getItem(Dummy::class, ['id' => 1], 'foo', $context)); + } + + public function testGetItemWithExecuteOptions() + { + $context = ['foo' => 'bar', 'fetch_data' => true, IdentifierConverterInterface::HAS_IDENTIFIER_CONVERTER => true]; + + $matchProphecy = $this->prophesize(Match::class); + $matchProphecy->field('id')->willReturn($matchProphecy)->shouldBeCalled(); + $matchProphecy->equals(1)->shouldBeCalled(); + + $iterator = $this->prophesize(Iterator::class); + $result = new \stdClass(); + $iterator->current()->willReturn($result)->shouldBeCalled(); + + $aggregationBuilderProphecy = $this->prophesize(Builder::class); + $aggregationBuilderProphecy->match()->willReturn($matchProphecy->reveal())->shouldBeCalled(); + $aggregationBuilderProphecy->hydrate(Dummy::class)->willReturn($aggregationBuilderProphecy)->shouldBeCalled(); + $aggregationBuilderProphecy->execute(['allowDiskUse' => true])->willReturn($iterator->reveal())->shouldBeCalled(); $aggregationBuilder = $aggregationBuilderProphecy->reveal(); [$propertyNameCollectionFactory, $propertyMetadataFactory] = $this->getMetadataFactories(Dummy::class, [ @@ -65,10 +112,17 @@ public function testGetItemSingleIdentifier() ]); $managerRegistry = $this->getManagerRegistry(Dummy::class, $aggregationBuilder); + $this->resourceMetadataFactoryProphecy->create(Dummy::class)->willReturn(new ResourceMetadata( + 'Dummy', + null, + null, + ['foo' => ['doctrine_mongodb' => ['execute_options' => ['allowDiskUse' => true]]]] + )); + $extensionProphecy = $this->prophesize(AggregationItemExtensionInterface::class); $extensionProphecy->applyToItem($aggregationBuilder, Dummy::class, ['id' => 1], 'foo', $context)->shouldBeCalled(); - $dataProvider = new ItemDataProvider($managerRegistry, $propertyNameCollectionFactory, $propertyMetadataFactory, [$extensionProphecy->reveal()]); + $dataProvider = new ItemDataProvider($managerRegistry, $this->resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory, [$extensionProphecy->reveal()]); $this->assertEquals($result, $dataProvider->getItem(Dummy::class, ['id' => 1], 'foo', $context)); } @@ -88,7 +142,7 @@ public function testGetItemDoubleIdentifier() $aggregationBuilderProphecy = $this->prophesize(Builder::class); $aggregationBuilderProphecy->match()->willReturn($matchProphecy->reveal())->shouldBeCalled(); $aggregationBuilderProphecy->hydrate(Dummy::class)->willReturn($aggregationBuilderProphecy)->shouldBeCalled(); - $aggregationBuilderProphecy->execute()->willReturn($iterator->reveal())->shouldBeCalled(); + $aggregationBuilderProphecy->execute([])->willReturn($iterator->reveal())->shouldBeCalled(); $aggregationBuilder = $aggregationBuilderProphecy->reveal(); [$propertyNameCollectionFactory, $propertyMetadataFactory] = $this->getMetadataFactories(Dummy::class, [ @@ -97,11 +151,13 @@ public function testGetItemDoubleIdentifier() ]); $managerRegistry = $this->getManagerRegistry(Dummy::class, $aggregationBuilder); + $this->resourceMetadataFactoryProphecy->create(Dummy::class)->willReturn(new ResourceMetadata()); + $context = [IdentifierConverterInterface::HAS_IDENTIFIER_CONVERTER => true]; $extensionProphecy = $this->prophesize(AggregationItemExtensionInterface::class); $extensionProphecy->applyToItem($aggregationBuilder, Dummy::class, ['ida' => 1, 'idb' => 2], 'foo', $context)->shouldBeCalled(); - $dataProvider = new ItemDataProvider($managerRegistry, $propertyNameCollectionFactory, $propertyMetadataFactory, [$extensionProphecy->reveal()]); + $dataProvider = new ItemDataProvider($managerRegistry, $this->resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory, [$extensionProphecy->reveal()]); $this->assertEquals($result, $dataProvider->getItem(Dummy::class, ['ida' => 1, 'idb' => 2], 'foo', $context)); } @@ -126,7 +182,7 @@ public function testGetItemWrongCompositeIdentifier() ], ]); - $dataProvider = new ItemDataProvider($managerRegistry, $propertyNameCollectionFactory, $propertyMetadataFactory); + $dataProvider = new ItemDataProvider($managerRegistry, $this->resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory); $dataProvider->getItem(Dummy::class, 'ida=1;', 'foo'); } @@ -151,7 +207,7 @@ public function testAggregationResultExtension() $extensionProphecy->supportsResult(Dummy::class, 'foo', $context)->willReturn(true)->shouldBeCalled(); $extensionProphecy->getResult($aggregationBuilder, Dummy::class, 'foo', $context)->willReturn([])->shouldBeCalled(); - $dataProvider = new ItemDataProvider($managerRegistry, $propertyNameCollectionFactory, $propertyMetadataFactory, [$extensionProphecy->reveal()]); + $dataProvider = new ItemDataProvider($managerRegistry, $this->resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory, [$extensionProphecy->reveal()]); $this->assertEquals([], $dataProvider->getItem(Dummy::class, ['id' => 1], 'foo', $context)); } @@ -167,7 +223,7 @@ public function testUnsupportedClass() 'id', ]); - $dataProvider = new ItemDataProvider($managerRegistryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory, [$extensionProphecy->reveal()]); + $dataProvider = new ItemDataProvider($managerRegistryProphecy->reveal(), $this->resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory, [$extensionProphecy->reveal()]); $this->assertFalse($dataProvider->supports(Dummy::class, 'foo')); } @@ -190,7 +246,7 @@ public function testCannotCreateAggregationBuilder() 'id', ]); - (new ItemDataProvider($managerRegistryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory, [$extensionProphecy->reveal()]))->getItem(Dummy::class, 'foo', null, [IdentifierConverterInterface::HAS_IDENTIFIER_CONVERTER => true]); + (new ItemDataProvider($managerRegistryProphecy->reveal(), $this->resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory, [$extensionProphecy->reveal()]))->getItem(Dummy::class, 'foo', null, [IdentifierConverterInterface::HAS_IDENTIFIER_CONVERTER => true]); } /** diff --git a/tests/Bridge/Doctrine/MongoDbOdm/SubresourceDataProviderTest.php b/tests/Bridge/Doctrine/MongoDbOdm/SubresourceDataProviderTest.php index 301843efa60..5745364d34f 100644 --- a/tests/Bridge/Doctrine/MongoDbOdm/SubresourceDataProviderTest.php +++ b/tests/Bridge/Doctrine/MongoDbOdm/SubresourceDataProviderTest.php @@ -22,6 +22,8 @@ use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Core\Metadata\Property\PropertyMetadata; use ApiPlatform\Core\Metadata\Property\PropertyNameCollection; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\Dummy; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\RelatedDummy; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\RelatedOwningDummy; @@ -45,6 +47,18 @@ */ class SubresourceDataProviderTest extends TestCase { + private $resourceMetadataFactoryProphecy; + + /** + * {@inheritdoc} + */ + protected function setUp(): void + { + parent::setUp(); + + $this->resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + } + private function getMetadataProphecies(array $resourceClassesIdentifiers) { $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); @@ -94,7 +108,7 @@ public function testNotASubresource() $aggregationBuilder = $this->prophesize(Builder::class)->reveal(); $managerRegistry = $this->getManagerRegistryProphecy($aggregationBuilder, $identifiers, Dummy::class); - $dataProvider = new SubresourceDataProvider($managerRegistry, $propertyNameCollectionFactory, $propertyMetadataFactory, []); + $dataProvider = new SubresourceDataProvider($managerRegistry, $this->resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory, []); $dataProvider->getSubresource(Dummy::class, ['id' => 1], []); } @@ -126,7 +140,7 @@ public function testGetSubresource() $dummyIterator = $this->prophesize(Iterator::class); $dummyIterator->toArray()->shouldBeCalled()->willReturn([['_id' => 1, 'relatedDummies' => [['_id' => 2]]]]); - $dummyAggregationBuilder->execute()->shouldBeCalled()->willReturn($dummyIterator->reveal()); + $dummyAggregationBuilder->execute([])->shouldBeCalled()->willReturn($dummyIterator->reveal()); $managerProphecy->createAggregationBuilder(Dummy::class)->shouldBeCalled()->willReturn($dummyAggregationBuilder->reveal()); @@ -137,16 +151,18 @@ public function testGetSubresource() $iterator = $this->prophesize(Iterator::class); $iterator->toArray()->shouldBeCalled()->willReturn([]); - $aggregationBuilder->execute()->shouldBeCalled()->willReturn($iterator->reveal()); + $aggregationBuilder->execute([])->shouldBeCalled()->willReturn($iterator->reveal()); $aggregationBuilder->hydrate(RelatedDummy::class)->shouldBeCalled()->willReturn($aggregationBuilder); $managerRegistryProphecy = $this->prophesize(ManagerRegistry::class); $managerRegistryProphecy->getManagerForClass(RelatedDummy::class)->shouldBeCalled()->willReturn($managerProphecy->reveal()); $managerRegistryProphecy->getManagerForClass(Dummy::class)->shouldBeCalled()->willReturn($managerProphecy->reveal()); + $this->resourceMetadataFactoryProphecy->create(RelatedDummy::class)->willReturn(new ResourceMetadata()); + [$propertyNameCollectionFactory, $propertyMetadataFactory] = $this->getMetadataProphecies([Dummy::class => ['id']]); - $dataProvider = new SubresourceDataProvider($managerRegistryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory); + $dataProvider = new SubresourceDataProvider($managerRegistryProphecy->reveal(), $this->resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory); $context = ['property' => 'relatedDummies', 'identifiers' => [['id', Dummy::class]], 'collection' => true, IdentifierConverterInterface::HAS_IDENTIFIER_CONVERTER => true]; @@ -171,7 +187,7 @@ public function testGetSubSubresourceItem() $dummyIterator = $this->prophesize(Iterator::class); $dummyIterator->toArray()->shouldBeCalled()->willReturn([['_id' => 1, 'relatedDummies' => [['_id' => 2]]]]); - $dummyAggregationBuilder->execute()->shouldBeCalled()->willReturn($dummyIterator->reveal()); + $dummyAggregationBuilder->execute([])->shouldBeCalled()->willReturn($dummyIterator->reveal()); $classMetadataProphecy = $this->prophesize(ClassMetadata::class); $classMetadataProphecy->hasAssociation('relatedDummies')->willReturn(true)->shouldBeCalled(); @@ -198,7 +214,7 @@ public function testGetSubSubresourceItem() $rIterator = $this->prophesize(Iterator::class); $rIterator->toArray()->shouldBeCalled()->willReturn([['_id' => 1, 'thirdLevel' => [['_id' => 3]]]]); - $rAggregationBuilder->execute()->shouldBeCalled()->willReturn($rIterator->reveal()); + $rAggregationBuilder->execute([])->shouldBeCalled()->willReturn($rIterator->reveal()); $rClassMetadataProphecy = $this->prophesize(ClassMetadata::class); $rClassMetadataProphecy->hasAssociation('thirdLevel')->shouldBeCalled()->willReturn(true); @@ -220,7 +236,7 @@ public function testGetSubSubresourceItem() $iterator = $this->prophesize(Iterator::class); $iterator->current()->shouldBeCalled()->willReturn($result); - $aggregationBuilder->execute()->shouldBeCalled()->willReturn($iterator->reveal()); + $aggregationBuilder->execute([])->shouldBeCalled()->willReturn($iterator->reveal()); $aggregationBuilder->hydrate(ThirdLevel::class)->shouldBeCalled()->willReturn($aggregationBuilder); $repositoryProphecy = $this->prophesize(DocumentRepository::class); @@ -231,15 +247,114 @@ public function testGetSubSubresourceItem() $managerRegistryProphecy->getManagerForClass(ThirdLevel::class)->shouldBeCalled()->willReturn($managerProphecy->reveal()); + $this->resourceMetadataFactoryProphecy->create(ThirdLevel::class)->willReturn(new ResourceMetadata()); + [$propertyNameCollectionFactory, $propertyMetadataFactory] = $this->getMetadataProphecies([Dummy::class => $identifiers, RelatedDummy::class => $identifiers]); - $dataProvider = new SubresourceDataProvider($managerRegistryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory); + $dataProvider = new SubresourceDataProvider($managerRegistryProphecy->reveal(), $this->resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory); $context = ['property' => 'thirdLevel', 'identifiers' => [['id', Dummy::class], ['relatedDummies', RelatedDummy::class]], 'collection' => false, IdentifierConverterInterface::HAS_IDENTIFIER_CONVERTER => true]; $this->assertEquals($result, $dataProvider->getSubresource(ThirdLevel::class, ['id' => ['id' => 1], 'relatedDummies' => ['id' => 1]], $context)); } + public function testGetSubSubresourceItemWithExecuteOptions() + { + $managerRegistryProphecy = $this->prophesize(ManagerRegistry::class); + $identifiers = ['id']; + + // First manager (Dummy) + $dummyAggregationBuilder = $this->prophesize(Builder::class); + $dummyLookup = $this->prophesize(Lookup::class); + $dummyLookup->alias('relatedDummies')->shouldBeCalled(); + $dummyAggregationBuilder->lookup('relatedDummies')->shouldBeCalled()->willReturn($dummyLookup->reveal()); + + $dummyMatch = $this->prophesize(Match::class); + $dummyMatch->equals(1)->shouldBeCalled(); + $dummyMatch->field('id')->shouldBeCalled()->willReturn($dummyMatch); + $dummyAggregationBuilder->match()->shouldBeCalled()->willReturn($dummyMatch->reveal()); + + $dummyIterator = $this->prophesize(Iterator::class); + $dummyIterator->toArray()->shouldBeCalled()->willReturn([['_id' => 1, 'relatedDummies' => [['_id' => 2]]]]); + $dummyAggregationBuilder->execute(['allowDiskUse' => true])->shouldBeCalled()->willReturn($dummyIterator->reveal()); + + $classMetadataProphecy = $this->prophesize(ClassMetadata::class); + $classMetadataProphecy->hasAssociation('relatedDummies')->willReturn(true)->shouldBeCalled(); + + $dummyManagerProphecy = $this->prophesize(DocumentManager::class); + $dummyManagerProphecy->createAggregationBuilder(Dummy::class)->shouldBeCalled()->willReturn($dummyAggregationBuilder->reveal()); + $dummyManagerProphecy->getClassMetadata(Dummy::class)->shouldBeCalled()->willReturn($classMetadataProphecy->reveal()); + + $managerRegistryProphecy->getManagerForClass(Dummy::class)->shouldBeCalled()->willReturn($dummyManagerProphecy->reveal()); + + // Second manager (RelatedDummy) + $rAggregationBuilder = $this->prophesize(Builder::class); + $rLookup = $this->prophesize(Lookup::class); + $rLookup->alias('thirdLevel')->shouldBeCalled(); + $rAggregationBuilder->lookup('thirdLevel')->shouldBeCalled()->willReturn($rLookup->reveal()); + + $rMatch = $this->prophesize(Match::class); + $rMatch->equals(1)->shouldBeCalled(); + $rMatch->field('id')->shouldBeCalled()->willReturn($rMatch); + $previousRMatch = $this->prophesize(Match::class); + $previousRMatch->in([2])->shouldBeCalled(); + $previousRMatch->field('_id')->shouldBeCalled()->willReturn($previousRMatch); + $rAggregationBuilder->match()->shouldBeCalled()->willReturn($rMatch->reveal(), $previousRMatch->reveal()); + + $rIterator = $this->prophesize(Iterator::class); + $rIterator->toArray()->shouldBeCalled()->willReturn([['_id' => 1, 'thirdLevel' => [['_id' => 3]]]]); + $rAggregationBuilder->execute(['allowDiskUse' => true])->shouldBeCalled()->willReturn($rIterator->reveal()); + + $rClassMetadataProphecy = $this->prophesize(ClassMetadata::class); + $rClassMetadataProphecy->hasAssociation('thirdLevel')->shouldBeCalled()->willReturn(true); + + $rDummyManagerProphecy = $this->prophesize(DocumentManager::class); + $rDummyManagerProphecy->createAggregationBuilder(RelatedDummy::class)->shouldBeCalled()->willReturn($rAggregationBuilder->reveal()); + $rDummyManagerProphecy->getClassMetadata(RelatedDummy::class)->shouldBeCalled()->willReturn($rClassMetadataProphecy->reveal()); + + $managerRegistryProphecy->getManagerForClass(RelatedDummy::class)->shouldBeCalled()->willReturn($rDummyManagerProphecy->reveal()); + + $result = new \stdClass(); + // Origin manager (ThirdLevel) + $aggregationBuilder = $this->prophesize(Builder::class); + + $match = $this->prophesize(Match::class); + $match->in([3])->shouldBeCalled(); + $match->field('_id')->shouldBeCalled()->willReturn($match); + $aggregationBuilder->match()->shouldBeCalled()->willReturn($match); + + $iterator = $this->prophesize(Iterator::class); + $iterator->current()->shouldBeCalled()->willReturn($result); + $aggregationBuilder->execute(['allowDiskUse' => true])->shouldBeCalled()->willReturn($iterator->reveal()); + $aggregationBuilder->hydrate(ThirdLevel::class)->shouldBeCalled()->willReturn($aggregationBuilder); + + $repositoryProphecy = $this->prophesize(DocumentRepository::class); + $repositoryProphecy->createAggregationBuilder()->shouldBeCalled()->willReturn($aggregationBuilder->reveal()); + + $managerProphecy = $this->prophesize(DocumentManager::class); + $managerProphecy->getRepository(ThirdLevel::class)->shouldBeCalled()->willReturn($repositoryProphecy->reveal()); + + $managerRegistryProphecy->getManagerForClass(ThirdLevel::class)->shouldBeCalled()->willReturn($managerProphecy->reveal()); + + $this->resourceMetadataFactoryProphecy->create(ThirdLevel::class)->willReturn(new ResourceMetadata( + 'ThirdLevel', + null, + null, + null, + null, + null, + ['third_level_operation_name' => ['doctrine_mongodb' => ['execute_options' => ['allowDiskUse' => true]]]] + )); + + [$propertyNameCollectionFactory, $propertyMetadataFactory] = $this->getMetadataProphecies([Dummy::class => $identifiers, RelatedDummy::class => $identifiers]); + + $dataProvider = new SubresourceDataProvider($managerRegistryProphecy->reveal(), $this->resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory); + + $context = ['property' => 'thirdLevel', 'identifiers' => [['id', Dummy::class], ['relatedDummies', RelatedDummy::class]], 'collection' => false, IdentifierConverterInterface::HAS_IDENTIFIER_CONVERTER => true]; + + $this->assertEquals($result, $dataProvider->getSubresource(ThirdLevel::class, ['id' => ['id' => 1], 'relatedDummies' => ['id' => 1]], $context, 'third_level_operation_name')); + } + public function testGetSubresourceOneToOneOwningRelation() { // RelatedOwningDummy OneToOne Dummy @@ -274,16 +389,18 @@ public function testGetSubresourceOneToOneOwningRelation() $iterator->toArray()->shouldBeCalled()->willReturn([['_id' => 1, 'ownedDummy' => [['_id' => 3]]]]); $result = new \stdClass(); $iterator->current()->shouldBeCalled()->willReturn($result); - $aggregationBuilder->execute()->shouldBeCalled()->willReturn($iterator->reveal()); + $aggregationBuilder->execute([])->shouldBeCalled()->willReturn($iterator->reveal()); $aggregationBuilder->hydrate(RelatedOwningDummy::class)->shouldBeCalled()->willReturn($aggregationBuilder); $managerRegistryProphecy = $this->prophesize(ManagerRegistry::class); $managerRegistryProphecy->getManagerForClass(RelatedOwningDummy::class)->shouldBeCalled()->willReturn($managerProphecy->reveal()); $managerRegistryProphecy->getManagerForClass(Dummy::class)->shouldBeCalled()->willReturn($managerProphecy->reveal()); + $this->resourceMetadataFactoryProphecy->create(RelatedOwningDummy::class)->willReturn(new ResourceMetadata()); + [$propertyNameCollectionFactory, $propertyMetadataFactory] = $this->getMetadataProphecies([Dummy::class => $identifiers]); - $dataProvider = new SubresourceDataProvider($managerRegistryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory); + $dataProvider = new SubresourceDataProvider($managerRegistryProphecy->reveal(), $this->resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory); $context = ['property' => 'ownedDummy', 'identifiers' => [['id', Dummy::class]], 'collection' => false, IdentifierConverterInterface::HAS_IDENTIFIER_CONVERTER => true]; @@ -321,12 +438,14 @@ public function testAggregationResultExtension() $iterator = $this->prophesize(Iterator::class); $iterator->toArray()->shouldBeCalled()->willReturn([['_id' => 1, 'relatedDummies' => [['_id' => 3]]]]); - $aggregationBuilder->execute()->shouldBeCalled()->willReturn($iterator->reveal()); + $aggregationBuilder->execute([])->shouldBeCalled()->willReturn($iterator->reveal()); $managerRegistryProphecy = $this->prophesize(ManagerRegistry::class); $managerRegistryProphecy->getManagerForClass(RelatedDummy::class)->shouldBeCalled()->willReturn($managerProphecy->reveal()); $managerRegistryProphecy->getManagerForClass(Dummy::class)->shouldBeCalled()->willReturn($managerProphecy->reveal()); + $this->resourceMetadataFactoryProphecy->create(RelatedDummy::class)->willReturn(new ResourceMetadata()); + [$propertyNameCollectionFactory, $propertyMetadataFactory] = $this->getMetadataProphecies([Dummy::class => $identifiers]); $extensionProphecy = $this->prophesize(AggregationResultCollectionExtensionInterface::class); @@ -334,7 +453,7 @@ public function testAggregationResultExtension() $extensionProphecy->supportsResult(RelatedDummy::class, null, Argument::type('array'))->willReturn(true)->shouldBeCalled(); $extensionProphecy->getResult($aggregationBuilder, RelatedDummy::class, null, Argument::type('array'))->willReturn([])->shouldBeCalled(); - $dataProvider = new SubresourceDataProvider($managerRegistryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory, [$extensionProphecy->reveal()]); + $dataProvider = new SubresourceDataProvider($managerRegistryProphecy->reveal(), $this->resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory, [$extensionProphecy->reveal()]); $context = ['property' => 'relatedDummies', 'identifiers' => [['id', Dummy::class]], 'collection' => true, IdentifierConverterInterface::HAS_IDENTIFIER_CONVERTER => true]; @@ -357,7 +476,7 @@ public function testCannotCreateQueryBuilder() [$propertyNameCollectionFactory, $propertyMetadataFactory] = $this->getMetadataProphecies([Dummy::class => $identifiers]); - $dataProvider = new SubresourceDataProvider($managerRegistryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory); + $dataProvider = new SubresourceDataProvider($managerRegistryProphecy->reveal(), $this->resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory); $dataProvider->getSubresource(Dummy::class, ['id' => 1], []); } @@ -371,7 +490,7 @@ public function testThrowResourceClassNotSupportedException() [$propertyNameCollectionFactory, $propertyMetadataFactory] = $this->getMetadataProphecies([Dummy::class => $identifiers]); - $dataProvider = new SubresourceDataProvider($managerRegistryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory); + $dataProvider = new SubresourceDataProvider($managerRegistryProphecy->reveal(), $this->resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory); $dataProvider->getSubresource(Dummy::class, ['id' => 1], []); } @@ -401,7 +520,7 @@ public function testGetSubresourceCollectionItem() $rIterator = $this->prophesize(Iterator::class); $rIterator->current()->shouldBeCalled()->willReturn($result); - $rAggregationBuilder->execute()->shouldBeCalled()->willReturn($rIterator->reveal()); + $rAggregationBuilder->execute([])->shouldBeCalled()->willReturn($rIterator->reveal()); $rAggregationBuilder->hydrate(RelatedDummy::class)->shouldBeCalled()->willReturn($rAggregationBuilder); $aggregationBuilder = $this->prophesize(Builder::class); @@ -411,9 +530,11 @@ public function testGetSubresourceCollectionItem() $rDummyManagerProphecy->getRepository(RelatedDummy::class)->shouldBeCalled()->willReturn($repositoryProphecy->reveal()); + $this->resourceMetadataFactoryProphecy->create(RelatedDummy::class)->willReturn(new ResourceMetadata()); + [$propertyNameCollectionFactory, $propertyMetadataFactory] = $this->getMetadataProphecies([Dummy::class => $identifiers, RelatedDummy::class => $identifiers]); - $dataProvider = new SubresourceDataProvider($managerRegistryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory); + $dataProvider = new SubresourceDataProvider($managerRegistryProphecy->reveal(), $this->resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory); $context = ['property' => 'id', 'identifiers' => [['id', Dummy::class, true], ['relatedDummies', RelatedDummy::class, true]], 'collection' => false, IdentifierConverterInterface::HAS_IDENTIFIER_CONVERTER => true]; diff --git a/tests/Fixtures/TestBundle/Document/Dummy.php b/tests/Fixtures/TestBundle/Document/Dummy.php index 2ddc9f878e8..a25c1bff186 100644 --- a/tests/Fixtures/TestBundle/Document/Dummy.php +++ b/tests/Fixtures/TestBundle/Document/Dummy.php @@ -27,6 +27,11 @@ * @author Alexandre Delplace * * @ApiResource(attributes={ + * "doctrine_mongodb"={ + * "execute_options"={ + * "allowDiskUse"=true + * } + * }, * "filters"={ * "my_dummy.mongodb.boolean", * "my_dummy.mongodb.date", From 4d4ee373956e75ea2f56d48148dbce533d5e5498 Mon Sep 17 00:00:00 2001 From: Prou Yann Date: Wed, 2 Oct 2019 14:34:13 +0200 Subject: [PATCH 007/160] Feat: remove template pull request --- .github/PULL_REQUEST_TEMPLATE.md | 13 ------------- CONTRIBUTING.md | 5 ----- 2 files changed, 18 deletions(-) delete mode 100644 .github/PULL_REQUEST_TEMPLATE.md diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md deleted file mode 100644 index eb455a2e2a8..00000000000 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ /dev/null @@ -1,13 +0,0 @@ - -| Q | A -| ------------- | --- -| Bug fix? | yes/no -| New feature? | yes/no -| BC breaks? | no -| Deprecations? | no -| Tests pass? | yes -| Fixed tickets | #1234, #5678 -| License | MIT -| Doc PR | api-platform/doc#1234 - - diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6d491efdebe..41610be97b2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -18,11 +18,6 @@ Then, if it appears that it's a real bug, you may report it using GitHub by foll > _NOTE:_ Don't hesitate giving as much information as you can (OS, PHP version extensions...) -### Security Issues - -If you find a security issue, send a mail to Kévin Dunglas . **Please do not report security problems -publicly**. We will disclose details of the issue and credit you after having released a new version including a fix. - ## Pull Requests ### Writing a Pull Request From 1931f1674de90b281ef2abd0771e5c6cba26ba06 Mon Sep 17 00:00:00 2001 From: Sergii Pavlenko Date: Mon, 14 Oct 2019 17:01:12 +0200 Subject: [PATCH 008/160] Context stamp (#3157) * Issue #3082: Add and use ContextStamp. * Issue #3082: Add tests. * Issue #3157: Correct passing of context. * Issue #3157: Minor corrections. --- src/Bridge/Symfony/Messenger/ContextStamp.php | 41 +++++++++++++++++++ .../Symfony/Messenger/DataPersister.php | 5 ++- .../Symfony/Messenger/ContextStampTest.php | 35 ++++++++++++++++ .../Symfony/Messenger/DataPersisterTest.php | 10 ++++- 4 files changed, 88 insertions(+), 3 deletions(-) create mode 100644 src/Bridge/Symfony/Messenger/ContextStamp.php create mode 100644 tests/Bridge/Symfony/Messenger/ContextStampTest.php diff --git a/src/Bridge/Symfony/Messenger/ContextStamp.php b/src/Bridge/Symfony/Messenger/ContextStamp.php new file mode 100644 index 00000000000..40afe1b1384 --- /dev/null +++ b/src/Bridge/Symfony/Messenger/ContextStamp.php @@ -0,0 +1,41 @@ + + * + * 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\Bridge\Symfony\Messenger; + +use Symfony\Component\Messenger\Stamp\StampInterface; + +/** + * An envelope stamp with context which related to a message. + * + * @experimental + * + * @author Sergii Pavlenko + */ +final class ContextStamp implements StampInterface +{ + private $context; + + public function __construct(array $context = []) + { + $this->context = $context; + } + + /** + * Get the context related to a message. + */ + public function getContext(): array + { + return $this->context; + } +} diff --git a/src/Bridge/Symfony/Messenger/DataPersister.php b/src/Bridge/Symfony/Messenger/DataPersister.php index 525cd71b304..faebcad48e8 100644 --- a/src/Bridge/Symfony/Messenger/DataPersister.php +++ b/src/Bridge/Symfony/Messenger/DataPersister.php @@ -75,7 +75,10 @@ public function supports($data, array $context = []): bool */ public function persist($data, array $context = []) { - $envelope = $this->dispatch($data); + $envelope = $this->dispatch( + (new Envelope($data)) + ->with(new ContextStamp($context)) + ); $handledStamp = $envelope->last(HandledStamp::class); if (!$handledStamp instanceof HandledStamp) { diff --git a/tests/Bridge/Symfony/Messenger/ContextStampTest.php b/tests/Bridge/Symfony/Messenger/ContextStampTest.php new file mode 100644 index 00000000000..0c2cbddf74f --- /dev/null +++ b/tests/Bridge/Symfony/Messenger/ContextStampTest.php @@ -0,0 +1,35 @@ + + * + * 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\Tests\Bridge\Symfony\Messenger; + +use ApiPlatform\Core\Bridge\Symfony\Messenger\ContextStamp; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Messenger\Stamp\StampInterface; + +/** + * @author Sergii Pavlenko + */ +class ContextStampTest extends TestCase +{ + public function testConstruct() + { + $this->assertInstanceOf(StampInterface::class, new ContextStamp()); + } + + public function testGetContext() + { + $contextStamp = new ContextStamp(); + $this->assertIsArray($contextStamp->getContext()); + } +} diff --git a/tests/Bridge/Symfony/Messenger/DataPersisterTest.php b/tests/Bridge/Symfony/Messenger/DataPersisterTest.php index 2ff2d0dba05..781ab6277b0 100644 --- a/tests/Bridge/Symfony/Messenger/DataPersisterTest.php +++ b/tests/Bridge/Symfony/Messenger/DataPersisterTest.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Core\Tests\Bridge\Symfony\Messenger; +use ApiPlatform\Core\Bridge\Symfony\Messenger\ContextStamp; use ApiPlatform\Core\Bridge\Symfony\Messenger\DataPersister; use ApiPlatform\Core\Bridge\Symfony\Messenger\RemoveStamp; use ApiPlatform\Core\Exception\ResourceClassNotFoundException; @@ -56,7 +57,9 @@ public function testPersist() $dummy = new Dummy(); $messageBus = $this->prophesize(MessageBusInterface::class); - $messageBus->dispatch($dummy)->willReturn(new Envelope($dummy))->shouldBeCalled(); + $messageBus->dispatch(Argument::that(function (Envelope $envelope) use ($dummy) { + return $dummy === $envelope->getMessage() && null !== $envelope->last(ContextStamp::class); + }))->willReturn(new Envelope($dummy))->shouldBeCalled(); $dataPersister = new DataPersister($this->prophesize(ResourceMetadataFactoryInterface::class)->reveal(), $messageBus->reveal()); $this->assertSame($dummy, $dataPersister->persist($dummy)); @@ -67,6 +70,7 @@ public function testRemove() $dummy = new Dummy(); $messageBus = $this->prophesize(MessageBusInterface::class); + $messageBus->dispatch(Argument::that(function (Envelope $envelope) use ($dummy) { return $dummy === $envelope->getMessage() && null !== $envelope->last(RemoveStamp::class); }))->willReturn(new Envelope($dummy))->shouldBeCalled(); @@ -80,7 +84,9 @@ public function testHandle() $dummy = new Dummy(); $messageBus = $this->prophesize(MessageBusInterface::class); - $messageBus->dispatch($dummy)->willReturn((new Envelope($dummy))->with(new HandledStamp($dummy, 'DummyHandler::__invoke')))->shouldBeCalled(); + $messageBus->dispatch(Argument::that(function (Envelope $envelope) use ($dummy) { + return $dummy === $envelope->getMessage() && null !== $envelope->last(ContextStamp::class); + }))->willReturn((new Envelope($dummy))->with(new HandledStamp($dummy, 'DummyHandler::__invoke')))->shouldBeCalled(); $dataPersister = new DataPersister($this->prophesize(ResourceMetadataFactoryInterface::class)->reveal(), $messageBus->reveal()); $this->assertSame($dummy, $dataPersister->persist($dummy)); From 74ee7efeb5dae39f237f229b6a47c641af802a47 Mon Sep 17 00:00:00 2001 From: Beno!t POLASZEK Date: Mon, 14 Oct 2019 17:20:01 +0200 Subject: [PATCH 009/160] Global resource defaults implementation (#3151) * Add defaults configuration * Suggest defaults in configuration * Apply changes from code review * Add deprecation notices for legacy defaults * Apply @dunglas' suggestions --- src/Annotation/ApiResource.php | 49 ++++++++++ .../ApiPlatformExtension.php | 23 +++++ .../DependencyInjection/Configuration.php | 90 ++++++++++++++++--- .../Resources/config/metadata/annotation.xml | 1 + .../Bundle/Resources/config/metadata/yaml.xml | 1 + .../AnnotationResourceMetadataFactory.php | 18 ++-- .../ExtractorResourceMetadataFactory.php | 11 ++- .../ApiPlatformExtensionTest.php | 6 ++ .../AnnotationResourceMetadataFactoryTest.php | 32 +++++++ .../ExtractorResourceMetadataFactoryTest.php | 50 +++++++++++ 10 files changed, 263 insertions(+), 18 deletions(-) diff --git a/src/Annotation/ApiResource.php b/src/Annotation/ApiResource.php index 5f2a6ac1cf9..27a392564f9 100644 --- a/src/Annotation/ApiResource.php +++ b/src/Annotation/ApiResource.php @@ -73,6 +73,55 @@ final class ApiResource { use AttributesHydratorTrait; + /** + * @internal + * + * @see \ApiPlatform\Core\Bridge\Symfony\Bundle\DependencyInjection\Configuration::addDefaultsSection + */ + public const CONFIGURABLE_DEFAULTS = [ + 'accessControl', + 'accessControlMessage', + 'security', + 'securityMessage', + 'securityPostDenormalize', + 'securityPostDenormalizeMessage', + 'cacheHeaders', + 'collectionOperations', + 'denormalizationContext', + 'deprecationReason', + 'description', + 'elasticsearch', + 'fetchPartial', + 'forceEager', + 'formats', + 'filters', + 'graphql', + 'hydraContext', + 'input', + 'iri', + 'itemOperations', + 'mercure', + 'messenger', + 'normalizationContext', + 'openapiContext', + 'order', + 'output', + 'paginationClientEnabled', + 'paginationClientItemsPerPage', + 'paginationClientPartial', + 'paginationEnabled', + 'paginationFetchJoinCollection', + 'paginationItemsPerPage', + 'maximumItemsPerPage', + 'paginationMaximumItemsPerPage', + 'paginationPartial', + 'paginationViaCursor', + 'routePrefix', + 'sunset', + 'swaggerContext', + 'validationGroups', + ]; + /** * @var string */ diff --git a/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index cf39090b833..d0fcbaaa300 100644 --- a/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -191,6 +191,29 @@ private function registerCommonConfiguration(ContainerBuilder $container, array if ($config['name_converter']) { $container->setAlias('api_platform.name_converter', $config['name_converter']); } + $container->setParameter('api_platform.defaults', $this->normalizeDefaults($config['defaults'] ?? [])); + } + + private function normalizeDefaults(array $defaults): array + { + $normalizedDefaults = ['attributes' => []]; + $rootLevelOptions = [ + 'description', + 'iri', + 'item_operations', + 'collection_operations', + 'graphql', + ]; + + foreach ($defaults as $option => $value) { + if (\in_array($option, $rootLevelOptions, true)) { + $normalizedDefaults[$option] = $value; + } else { + $normalizedDefaults['attributes'][$option] = $value; + } + } + + return $normalizedDefaults; } private function registerMetadataConfiguration(ContainerBuilder $container, array $config, XmlFileLoader $loader): void diff --git a/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php b/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php index b49c43b28ed..ca8a5b0d4f0 100644 --- a/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php +++ b/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Core\Bridge\Symfony\Bundle\DependencyInjection; +use ApiPlatform\Core\Annotation\ApiResource; use ApiPlatform\Core\Bridge\Elasticsearch\Metadata\Document\DocumentMetadata; use ApiPlatform\Core\Exception\FilterValidationException; use ApiPlatform\Core\Exception\InvalidArgumentException; @@ -33,6 +34,7 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\Serializer\Exception\ExceptionInterface as SerializerExceptionInterface; +use Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter; /** * The configuration of the bundle. @@ -128,13 +130,41 @@ public function getConfigTreeBuilder() ->canBeDisabled() ->addDefaultsIfNotSet() ->children() - ->booleanNode('enabled')->defaultTrue()->info('To enable or disable pagination for all resource collections by default.')->end() - ->booleanNode('partial')->defaultFalse()->info('To enable or disable partial pagination for all resource collections by default when pagination is enabled.')->end() - ->booleanNode('client_enabled')->defaultFalse()->info('To allow the client to enable or disable the pagination.')->end() - ->booleanNode('client_items_per_page')->defaultFalse()->info('To allow the client to set the number of items per page.')->end() - ->booleanNode('client_partial')->defaultFalse()->info('To allow the client to enable or disable partial pagination.')->end() - ->integerNode('items_per_page')->defaultValue(30)->info('The default number of items per page.')->end() - ->integerNode('maximum_items_per_page')->defaultNull()->info('The maximum number of items per page.')->end() + ->booleanNode('enabled') + ->setDeprecated('The use of the `collection.pagination.enabled` has been deprecated in 2.6 and will be removed in 3.0. Use `defaults.pagination_enabled` instead.') + ->defaultTrue() + ->info('To enable or disable pagination for all resource collections by default.') + ->end() + ->booleanNode('partial') + ->setDeprecated('The use of the `collection.pagination.partial` has been deprecated in 2.6 and will be removed in 3.0. Use `defaults.pagination_partial` instead.') + ->defaultFalse() + ->info('To enable or disable partial pagination for all resource collections by default when pagination is enabled.') + ->end() + ->booleanNode('client_enabled') + ->setDeprecated('The use of the `collection.pagination.client_enabled` has been deprecated in 2.6 and will be removed in 3.0. Use `defaults.pagination_client_enabled` instead.') + ->defaultFalse() + ->info('To allow the client to enable or disable the pagination.') + ->end() + ->booleanNode('client_items_per_page') + ->setDeprecated('The use of the `collection.pagination.client_items_per_page` has been deprecated in 2.6 and will be removed in 3.0. Use `defaults.pagination_client_items_per_page` instead.') + ->defaultFalse() + ->info('To allow the client to set the number of items per page.') + ->end() + ->booleanNode('client_partial') + ->setDeprecated('The use of the `collection.pagination.client_partial` has been deprecated in 2.6 and will be removed in 3.0. Use `defaults.pagination_client_partial` instead.') + ->defaultFalse() + ->info('To allow the client to enable or disable partial pagination.') + ->end() + ->integerNode('items_per_page') + ->setDeprecated('The use of the `collection.pagination.items_per_page` has been deprecated in 2.6 and will be removed in 3.0. Use `defaults.pagination_items_per_page` instead.') + ->defaultValue(30) + ->info('The default number of items per page.') + ->end() + ->integerNode('maximum_items_per_page') + ->setDeprecated('The use of the `collection.pagination.maximum_items_per_page` has been deprecated in 2.6 and will be removed in 3.0. Use `defaults.pagination_maximum_items_per_page` instead.') + ->defaultNull() + ->info('The maximum number of items per page.') + ->end() ->scalarNode('page_parameter_name')->defaultValue('page')->cannotBeEmpty()->info('The default name of the parameter handling the page number.')->end() ->scalarNode('enabled_parameter_name')->defaultValue('pagination')->cannotBeEmpty()->info('The name of the query parameter to enable or disable pagination.')->end() ->scalarNode('items_per_page_parameter_name')->defaultValue('itemsPerPage')->cannotBeEmpty()->info('The name of the query parameter to set the number of items per page.')->end() @@ -179,6 +209,8 @@ public function getConfigTreeBuilder() 'jsonld' => ['mime_types' => ['application/ld+json']], ]); + $this->addDefaultsSection($rootNode); + return $treeBuilder; } @@ -311,16 +343,30 @@ private function addHttpCacheSection(ArrayNodeDefinition $rootNode): void ->arrayNode('http_cache') ->addDefaultsIfNotSet() ->children() - ->booleanNode('etag')->defaultTrue()->info('Automatically generate etags for API responses.')->end() - ->integerNode('max_age')->defaultNull()->info('Default value for the response max age.')->end() - ->integerNode('shared_max_age')->defaultNull()->info('Default value for the response shared (proxy) max age.')->end() + ->booleanNode('etag') + ->setDeprecated('The use of the `http_cache.etag` has been deprecated in 2.6 and will be removed in 3.0. Use `defaults.cache_headers.etag` instead.') + ->defaultTrue() + ->info('Automatically generate etags for API responses.') + ->end() + ->integerNode('max_age') + ->setDeprecated('The use of the `http_cache.max_age` has been deprecated in 2.6 and will be removed in 3.0. Use `defaults.cache_headers.max_age` instead.') + ->defaultNull() + ->info('Default value for the response max age.') + ->end() + ->integerNode('shared_max_age') + ->setDeprecated('The use of the `http_cache.shared_max_age` has been deprecated in 2.6 and will be removed in 3.0. Use `defaults.cache_headers.shared_max_age` instead.') + ->defaultNull() + ->info('Default value for the response shared (proxy) max age.') + ->end() ->arrayNode('vary') + ->setDeprecated('The use of the `http_cache.vary` has been deprecated in 2.6 and will be removed in 3.0. Use `defaults.cache_headers.vary` instead.') ->defaultValue(['Accept']) ->prototype('scalar')->end() ->info('Default values of the "Vary" HTTP header.') ->end() ->booleanNode('public')->defaultNull()->info('To make all responses public by default.')->end() ->arrayNode('invalidation') + ->setDeprecated('The use of the `http_cache.invalidation` has been deprecated in 2.6 and will be removed in 3.0. Use `defaults.cache_headers.invalidation` instead.') ->info('Enable the tags-based cache invalidation system.') ->canBeEnabled() ->children() @@ -494,4 +540,28 @@ private function addFormatSection(ArrayNodeDefinition $rootNode, string $key, ar ->end() ->end(); } + + private function addDefaultsSection(ArrayNodeDefinition $rootNode): void + { + $nameConverter = new CamelCaseToSnakeCaseNameConverter(); + $defaultsNode = $rootNode->children()->arrayNode('defaults'); + + $defaultsNode + ->ignoreExtraKeys() + ->beforeNormalization() + ->always(function (array $defaults) use ($nameConverter) { + $normalizedDefaults = []; + foreach ($defaults as $option => $value) { + $option = $nameConverter->normalize($option); + $normalizedDefaults[$option] = $value; + } + + return $normalizedDefaults; + }); + + foreach (ApiResource::CONFIGURABLE_DEFAULTS as $attribute) { + $snakeCased = $nameConverter->normalize($attribute); + $defaultsNode->children()->variableNode($snakeCased); + } + } } diff --git a/src/Bridge/Symfony/Bundle/Resources/config/metadata/annotation.xml b/src/Bridge/Symfony/Bundle/Resources/config/metadata/annotation.xml index 70556d4f8bc..6440d8c921e 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/metadata/annotation.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/metadata/annotation.xml @@ -14,6 +14,7 @@ + %api_platform.defaults% diff --git a/src/Bridge/Symfony/Bundle/Resources/config/metadata/yaml.xml b/src/Bridge/Symfony/Bundle/Resources/config/metadata/yaml.xml index 1657eb99b86..7344a04a22e 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/metadata/yaml.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/metadata/yaml.xml @@ -18,6 +18,7 @@ + %api_platform.defaults% diff --git a/src/Metadata/Resource/Factory/AnnotationResourceMetadataFactory.php b/src/Metadata/Resource/Factory/AnnotationResourceMetadataFactory.php index d941e0e4918..c04364b5b9b 100644 --- a/src/Metadata/Resource/Factory/AnnotationResourceMetadataFactory.php +++ b/src/Metadata/Resource/Factory/AnnotationResourceMetadataFactory.php @@ -27,11 +27,13 @@ final class AnnotationResourceMetadataFactory implements ResourceMetadataFactory { private $reader; private $decorated; + private $defaults; - public function __construct(Reader $reader, ResourceMetadataFactoryInterface $decorated = null) + public function __construct(Reader $reader, ResourceMetadataFactoryInterface $decorated = null, array $defaults = []) { $this->reader = $reader; $this->decorated = $decorated; + $this->defaults = $defaults + ['attributes' => []]; } /** @@ -78,16 +80,18 @@ private function handleNotFound(?ResourceMetadata $parentPropertyMetadata, strin private function createMetadata(ApiResource $annotation, ResourceMetadata $parentResourceMetadata = null): ResourceMetadata { + $attributes = (null === $annotation->attributes && [] === $this->defaults['attributes']) ? null : (array) $annotation->attributes + $this->defaults['attributes']; + if (!$parentResourceMetadata) { return new ResourceMetadata( $annotation->shortName, - $annotation->description, - $annotation->iri, - $annotation->itemOperations, - $annotation->collectionOperations, - $annotation->attributes, + $annotation->description ?? $this->defaults['description'] ?? null, + $annotation->iri ?? $this->defaults['iri'] ?? null, + $annotation->itemOperations ?? $this->defaults['item_operations'] ?? null, + $annotation->collectionOperations ?? $this->defaults['collection_operations'] ?? null, + $attributes, $annotation->subresourceOperations, - $annotation->graphql + $annotation->graphql ?? $this->defaults['graphql'] ?? null ); } diff --git a/src/Metadata/Resource/Factory/ExtractorResourceMetadataFactory.php b/src/Metadata/Resource/Factory/ExtractorResourceMetadataFactory.php index 369380b4364..fee663cf97c 100644 --- a/src/Metadata/Resource/Factory/ExtractorResourceMetadataFactory.php +++ b/src/Metadata/Resource/Factory/ExtractorResourceMetadataFactory.php @@ -27,11 +27,13 @@ final class ExtractorResourceMetadataFactory implements ResourceMetadataFactoryI { private $extractor; private $decorated; + private $defaults; - public function __construct(ExtractorInterface $extractor, ResourceMetadataFactoryInterface $decorated = null) + public function __construct(ExtractorInterface $extractor, ResourceMetadataFactoryInterface $decorated = null, array $defaults = []) { $this->extractor = $extractor; $this->decorated = $decorated; + $this->defaults = $defaults + ['attributes' => []]; } /** @@ -52,6 +54,13 @@ public function create(string $resourceClass): ResourceMetadata return $this->handleNotFound($parentResourceMetadata, $resourceClass); } + $resource['description'] = $resource['description'] ?? $this->defaults['description'] ?? null; + $resource['iri'] = $resource['iri'] ?? $this->defaults['iri'] ?? null; + $resource['itemOperations'] = $resource['itemOperations'] ?? $this->defaults['item_operations'] ?? null; + $resource['collectionOperations'] = $resource['collectionOperations'] ?? $this->defaults['collection_operations'] ?? null; + $resource['graphql'] = $resource['graphql'] ?? $this->defaults['graphql'] ?? null; + $resource['attributes'] = (null === $resource['attributes'] && [] === $this->defaults['attributes']) ? null : (array) $resource['attributes'] + $this->defaults['attributes']; + return $this->update($parentResourceMetadata ?: new ResourceMetadata(), $resource); } diff --git a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php index 11f23d41b3f..9ea0aa8466b 100644 --- a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php +++ b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php @@ -144,6 +144,9 @@ class ApiPlatformExtensionTest extends TestCase 'doctrine_mongodb_odm' => [ 'enabled' => false, ], + 'defaults' => [ + 'attributes' => [], + ], ]]; private $extension; @@ -694,6 +697,7 @@ public function testEnableElasticsearch() $containerBuilderProphecy->registerForAutoconfiguration(RequestBodySearchCollectionExtensionInterface::class)->willReturn($this->childDefinitionProphecy)->shouldBeCalled(); $containerBuilderProphecy->setParameter('api_platform.elasticsearch.hosts', ['http://elasticsearch:9200'])->shouldBeCalled(); $containerBuilderProphecy->setParameter('api_platform.elasticsearch.mapping', [])->shouldBeCalled(); + $containerBuilderProphecy->setParameter('api_platform.defaults', ['attributes' => []])->shouldBeCalled(); $config = self::DEFAULT_CONFIG; $config['api_platform']['elasticsearch'] = [ @@ -804,6 +808,7 @@ private function getPartialContainerBuilderProphecy() 'api_platform.http_cache.shared_max_age' => null, 'api_platform.http_cache.vary' => ['Accept'], 'api_platform.http_cache.public' => null, + 'api_platform.defaults' => ['attributes' => []], 'api_platform.enable_entrypoint' => true, 'api_platform.enable_docs' => true, ]; @@ -1076,6 +1081,7 @@ private function getBaseContainerBuilderProphecy(array $doctrineIntegrationsToLo 'api_platform.resource_class_directories' => Argument::type('array'), 'api_platform.validator.serialize_payload_fields' => [], 'api_platform.elasticsearch.enabled' => false, + 'api_platform.defaults' => ['attributes' => []], ]; foreach ($parameters as $key => $value) { diff --git a/tests/Metadata/Resource/Factory/AnnotationResourceMetadataFactoryTest.php b/tests/Metadata/Resource/Factory/AnnotationResourceMetadataFactoryTest.php index 27835cd7468..744e3e66053 100644 --- a/tests/Metadata/Resource/Factory/AnnotationResourceMetadataFactoryTest.php +++ b/tests/Metadata/Resource/Factory/AnnotationResourceMetadataFactoryTest.php @@ -46,6 +46,38 @@ public function testCreate($reader, $decorated, string $expectedShortName, strin $this->assertEquals(['foo' => 'bar'], $metadata->getGraphql()); } + public function testCreateWithDefaults() + { + $defaults = [ + 'shortName' => 'Default shortname should not be ignored', + 'description' => 'CHANGEME!', + 'collection_operations' => ['get'], + 'item_operations' => ['get', 'put'], + 'attributes' => [ + 'pagination_items_per_page' => 4, + 'pagination_maximum_items_per_page' => 6, + ], + ]; + + $annotation = new ApiResource([ + 'itemOperations' => ['get', 'delete'], + 'attributes' => [ + 'pagination_maximum_items_per_page' => 10, + ], + ]); + $reader = $this->prophesize(Reader::class); + $reader->getClassAnnotation(Argument::type(\ReflectionClass::class), ApiResource::class)->willReturn($annotation)->shouldBeCalled(); + $factory = new AnnotationResourceMetadataFactory($reader->reveal(), null, $defaults); + $metadata = $factory->create(Dummy::class); + + $this->assertNull($metadata->getShortName()); + $this->assertEquals('CHANGEME!', $metadata->getDescription()); + $this->assertEquals(['get'], $metadata->getCollectionOperations()); + $this->assertEquals(['get', 'delete'], $metadata->getItemOperations()); + $this->assertEquals(4, $metadata->getAttribute('pagination_items_per_page')); + $this->assertEquals(10, $metadata->getAttribute('pagination_maximum_items_per_page')); + } + public function testCreateWithoutAttributes() { $annotation = new ApiResource([]); diff --git a/tests/Metadata/Resource/Factory/ExtractorResourceMetadataFactoryTest.php b/tests/Metadata/Resource/Factory/ExtractorResourceMetadataFactoryTest.php index 4b16a9cafb2..28aea8d4b52 100644 --- a/tests/Metadata/Resource/Factory/ExtractorResourceMetadataFactoryTest.php +++ b/tests/Metadata/Resource/Factory/ExtractorResourceMetadataFactoryTest.php @@ -15,6 +15,7 @@ use ApiPlatform\Core\Exception\InvalidArgumentException; use ApiPlatform\Core\Exception\ResourceClassNotFoundException; +use ApiPlatform\Core\Metadata\Extractor\ExtractorInterface; use ApiPlatform\Core\Metadata\Extractor\XmlExtractor; use ApiPlatform\Core\Metadata\Extractor\YamlExtractor; use ApiPlatform\Core\Metadata\Resource\Factory\ExtractorResourceMetadataFactory; @@ -23,6 +24,7 @@ use ApiPlatform\Core\Metadata\Resource\Factory\ShortNameResourceMetadataFactory; use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; use ApiPlatform\Core\Tests\Fixtures\DummyResourceInterface; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\FileConfigDummy; /** @@ -286,4 +288,52 @@ public function testItSupportsInterfaceAsAResource() $resourceMetadata = $shortNameResourceMetadataFactory->create(DummyResourceInterface::class); $this->assertSame('DummyResourceInterface', $resourceMetadata->getShortName()); } + + public function testItFallbacksToDefaultConfiguration() + { + $defaults = [ + 'shortName' => 'Default shortname should not be ignored', + 'description' => 'CHANGEME!', + 'collection_operations' => ['get'], + 'item_operations' => ['get', 'put'], + 'attributes' => [ + 'pagination_items_per_page' => 4, + 'pagination_maximum_items_per_page' => 6, + ], + ]; + $resourceConfiguration = [ + Dummy::class => [ + 'shortName' => null, + 'description' => null, + 'subresourceOperations' => null, + 'itemOperations' => ['get', 'delete'], + 'attributes' => [ + 'pagination_maximum_items_per_page' => 10, + ], + ], + ]; + + $extractor = new class($resourceConfiguration) implements ExtractorInterface { + private $resources; + + public function __construct(array $resources) + { + $this->resources = $resources; + } + + public function getResources(): array + { + return $this->resources; + } + }; + $factory = new ExtractorResourceMetadataFactory($extractor, null, $defaults); + $metadata = $factory->create(Dummy::class); + + $this->assertNull($metadata->getShortName()); + $this->assertEquals('CHANGEME!', $metadata->getDescription()); + $this->assertEquals(['get'], $metadata->getCollectionOperations()); + $this->assertEquals(['get', 'delete'], $metadata->getItemOperations()); + $this->assertEquals(4, $metadata->getAttribute('pagination_items_per_page')); + $this->assertEquals(10, $metadata->getAttribute('pagination_maximum_items_per_page')); + } } From 8e21ef2c6ecc7373230ef1457197cbab870a32fd Mon Sep 17 00:00:00 2001 From: Gert de Pagter Date: Mon, 28 Oct 2019 20:07:55 +0100 Subject: [PATCH 010/160] Make the base exception extend throwable This makes it more clear this interface can be 'caught' --- src/Exception/ExceptionInterface.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Exception/ExceptionInterface.php b/src/Exception/ExceptionInterface.php index 604c654605b..c2fd9eb4990 100644 --- a/src/Exception/ExceptionInterface.php +++ b/src/Exception/ExceptionInterface.php @@ -18,6 +18,6 @@ * * @author Kévin Dunglas */ -interface ExceptionInterface +interface ExceptionInterface extends \Throwable { } From 21bbe951ad8f480f9c538229395d3301dab690de Mon Sep 17 00:00:00 2001 From: Gert de Pagter Date: Mon, 28 Oct 2019 20:20:18 +0100 Subject: [PATCH 011/160] Use https instead of http --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 41610be97b2..dfdabf24917 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -28,7 +28,7 @@ See [the dedicated documentation entry](https://api-platform.com/docs/extra/rele ### Matching Coding Standards The API Platform project follows [Symfony coding standards](https://symfony.com/doc/current/contributing/code/standards.html). -But don't worry, you can fix CS issues automatically using the [PHP CS Fixer](http://cs.sensiolabs.org/) tool: +But don't worry, you can fix CS issues automatically using the [PHP CS Fixer](https://cs.sensiolabs.org/) tool: ```shell php-cs-fixer.phar fix From d22dda96b3fb2953bffbb5c45424f20a53059704 Mon Sep 17 00:00:00 2001 From: Gert de Pagter Date: Thu, 31 Oct 2019 11:36:39 +0100 Subject: [PATCH 012/160] Link to local XSD (#3218) This will allow IDEs to provide hints/ autocompletion --- phpunit.xml.dist | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 2f0a298995d..03838853d3b 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -2,7 +2,7 @@ Date: Thu, 31 Oct 2019 17:18:02 +0100 Subject: [PATCH 013/160] More Descriptive Input/Output class names --- src/JsonSchema/SchemaFactory.php | 4 +++- .../Serializer/DocumentationNormalizerV2Test.php | 16 ++++++++-------- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/src/JsonSchema/SchemaFactory.php b/src/JsonSchema/SchemaFactory.php index 1c5e300e87a..dc70791dccc 100644 --- a/src/JsonSchema/SchemaFactory.php +++ b/src/JsonSchema/SchemaFactory.php @@ -206,7 +206,9 @@ private function buildDefinitionName(string $resourceClass, string $format = 'js $prefix = $resourceMetadata->getShortName(); if (null !== $inputOrOutputClass && $resourceClass !== $inputOrOutputClass) { - $prefix .= ':'.md5($inputOrOutputClass); + $parts = explode('\\', $inputOrOutputClass); + $shortName = end($parts); + $prefix .= ':'.$shortName; } if (isset($this->distinctFormats[$format])) { diff --git a/tests/Swagger/Serializer/DocumentationNormalizerV2Test.php b/tests/Swagger/Serializer/DocumentationNormalizerV2Test.php index 9d07d3045f3..b8e1b605ae9 100644 --- a/tests/Swagger/Serializer/DocumentationNormalizerV2Test.php +++ b/tests/Swagger/Serializer/DocumentationNormalizerV2Test.php @@ -2780,7 +2780,7 @@ private function doTestNormalizeWithInputAndOutputClass(): void 'schema' => [ 'type' => 'array', 'items' => [ - '$ref' => '#/definitions/Dummy:300dcd476cef011532fb0ca7683395d7', + '$ref' => '#/definitions/Dummy:OutputDto', ], ], ], @@ -2805,7 +2805,7 @@ private function doTestNormalizeWithInputAndOutputClass(): void 201 => [ 'description' => 'Dummy resource created', 'schema' => [ - '$ref' => '#/definitions/Dummy:300dcd476cef011532fb0ca7683395d7', + '$ref' => '#/definitions/Dummy:OutputDto', ], ], 400 => [ @@ -2821,7 +2821,7 @@ private function doTestNormalizeWithInputAndOutputClass(): void 'in' => 'body', 'description' => 'The new Dummy resource', 'schema' => [ - '$ref' => '#/definitions/Dummy:b4f76c1a44965bd401aa23bb37618acc', + '$ref' => '#/definitions/Dummy:InputDto', ], ], ], @@ -2845,7 +2845,7 @@ private function doTestNormalizeWithInputAndOutputClass(): void 200 => [ 'description' => 'Dummy resource response', 'schema' => [ - '$ref' => '#/definitions/Dummy:300dcd476cef011532fb0ca7683395d7', + '$ref' => '#/definitions/Dummy:OutputDto', ], ], 404 => [ @@ -2871,7 +2871,7 @@ private function doTestNormalizeWithInputAndOutputClass(): void 'in' => 'body', 'description' => 'The updated Dummy resource', 'schema' => [ - '$ref' => '#/definitions/Dummy:b4f76c1a44965bd401aa23bb37618acc', + '$ref' => '#/definitions/Dummy:InputDto', ], ], ], @@ -2879,7 +2879,7 @@ private function doTestNormalizeWithInputAndOutputClass(): void 200 => [ 'description' => 'Dummy resource updated', 'schema' => [ - '$ref' => '#/definitions/Dummy:300dcd476cef011532fb0ca7683395d7', + '$ref' => '#/definitions/Dummy:OutputDto', ], ], 400 => [ @@ -2893,7 +2893,7 @@ private function doTestNormalizeWithInputAndOutputClass(): void ], ]), 'definitions' => new \ArrayObject([ - 'Dummy:300dcd476cef011532fb0ca7683395d7' => new \ArrayObject([ + 'Dummy:OutputDto' => new \ArrayObject([ 'type' => 'object', 'description' => 'This is a dummy.', 'externalDocs' => [ @@ -2911,7 +2911,7 @@ private function doTestNormalizeWithInputAndOutputClass(): void ]), ], ]), - 'Dummy:b4f76c1a44965bd401aa23bb37618acc' => new \ArrayObject([ + 'Dummy:InputDto' => new \ArrayObject([ 'type' => 'object', 'description' => 'This is a dummy.', 'externalDocs' => [ From 9920f1534aa18b01ca424a7aaae2748da35603fb Mon Sep 17 00:00:00 2001 From: Mahmood Bazdar Date: Sun, 17 Nov 2019 20:30:07 +0330 Subject: [PATCH 014/160] [GraphQL] Adding custom error format support (#3063) * Adding GraphQL custom error format support * Use error normalizers * Changing error format to follow GraphQL spec Co-authored-by: Alan Poulain --- CHANGELOG.md | 1 + features/graphql/authorization.feature | 12 +++ features/graphql/introspection.feature | 4 +- features/graphql/mutation.feature | 4 + .../Bundle/Resources/config/graphql.xml | 17 +++++ src/GraphQl/Action/EntrypointAction.php | 44 +++++------ .../Action/GraphQlPlaygroundAction.php | 2 + src/GraphQl/Action/GraphiQlAction.php | 2 + .../Factory/ItemMutationResolverFactory.php | 3 +- .../Resolver/Factory/ItemResolverFactory.php | 13 ++-- src/GraphQl/Resolver/Stage/ReadStage.php | 13 ++-- .../Stage/SecurityPostDenormalizeStage.php | 7 +- src/GraphQl/Resolver/Stage/SecurityStage.php | 7 +- src/GraphQl/Resolver/Stage/SerializeStage.php | 17 ++--- src/GraphQl/Resolver/Stage/ValidateStage.php | 12 +-- .../Serializer/Exception/ErrorNormalizer.php | 44 +++++++++++ .../Exception/HttpExceptionNormalizer.php | 52 +++++++++++++ .../Exception/RuntimeExceptionNormalizer.php | 49 +++++++++++++ .../ValidationExceptionNormalizer.php | 64 ++++++++++++++++ src/GraphQl/Serializer/ItemNormalizer.php | 2 +- src/GraphQl/Serializer/ObjectNormalizer.php | 2 +- src/GraphQl/Type/TypeBuilder.php | 4 +- .../ApiPlatformExtensionTest.php | 8 ++ tests/GraphQl/Action/EntrypointActionTest.php | 53 ++++++++------ .../ItemMutationResolverFactoryTest.php | 3 +- .../Factory/ItemResolverFactoryTest.php | 7 +- .../GraphQl/Resolver/Stage/ReadStageTest.php | 6 +- .../SecurityPostDenormalizeStageTest.php | 4 +- .../Resolver/Stage/SecurityStageTest.php | 4 +- .../Resolver/Stage/SerializeStageTest.php | 9 +-- .../Resolver/Stage/ValidateStageTest.php | 3 +- .../Exception/ErrorNormalizerTest.php | 53 ++++++++++++++ .../Exception/HttpExceptionNormalizerTest.php | 70 ++++++++++++++++++ .../RuntimeExceptionNormalizerTest.php | 55 ++++++++++++++ .../ValidationExceptionNormalizerTest.php | 73 +++++++++++++++++++ 35 files changed, 604 insertions(+), 119 deletions(-) create mode 100644 src/GraphQl/Serializer/Exception/ErrorNormalizer.php create mode 100644 src/GraphQl/Serializer/Exception/HttpExceptionNormalizer.php create mode 100644 src/GraphQl/Serializer/Exception/RuntimeExceptionNormalizer.php create mode 100644 src/GraphQl/Serializer/Exception/ValidationExceptionNormalizer.php create mode 100644 tests/GraphQl/Serializer/Exception/ErrorNormalizerTest.php create mode 100644 tests/GraphQl/Serializer/Exception/HttpExceptionNormalizerTest.php create mode 100644 tests/GraphQl/Serializer/Exception/RuntimeExceptionNormalizerTest.php create mode 100644 tests/GraphQl/Serializer/Exception/ValidationExceptionNormalizerTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 98ee0ff0e1e..78f8c6c86bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## 2.6.x-dev * MongoDB: Possibility to add execute options (aggregate command fields) for a resource, like `allowDiskUse` (#3144) +* GraphQL: Allow to format GraphQL errors based on exceptions (#3063) ## 2.5.1 diff --git a/features/graphql/authorization.feature b/features/graphql/authorization.feature index 7be5fb24499..a4cc77c4ccd 100644 --- a/features/graphql/authorization.feature +++ b/features/graphql/authorization.feature @@ -18,6 +18,8 @@ Feature: Authorization checking Then the response status code should be 200 And the response should be in JSON And the header "Content-Type" should be equal to "application/json" + And the JSON node "errors[0].extensions.status" should be equal to 403 + And the JSON node "errors[0].extensions.category" should be equal to user And the JSON node "errors[0].message" should be equal to "Access Denied." Scenario: An anonymous user tries to retrieve a secured collection @@ -38,6 +40,8 @@ Feature: Authorization checking Then the response status code should be 200 And the response should be in JSON And the header "Content-Type" should be equal to "application/json" + And the JSON node "errors[0].extensions.status" should be equal to 403 + And the JSON node "errors[0].extensions.category" should be equal to user And the JSON node "errors[0].message" should be equal to "Access Denied." Scenario: An admin can retrieve a secured collection @@ -79,6 +83,8 @@ Feature: Authorization checking And the response should be in JSON And the header "Content-Type" should be equal to "application/json" And the JSON node "data.securedDummies" should be null + And the JSON node "errors[0].extensions.status" should be equal to 403 + And the JSON node "errors[0].extensions.category" should be equal to user And the JSON node "errors[0].message" should be equal to "Access Denied." Scenario: An anonymous user tries to create a resource they are not allowed to @@ -96,6 +102,8 @@ Feature: Authorization checking Then the response status code should be 200 And the response should be in JSON And the header "Content-Type" should be equal to "application/json" + And the JSON node "errors[0].extensions.status" should be equal to 403 + And the JSON node "errors[0].extensions.category" should be equal to user And the JSON node "errors[0].message" should be equal to "Only admins can create a secured dummy." @createSchema @@ -151,6 +159,8 @@ Feature: Authorization checking Then the response status code should be 200 And the response should be in JSON And the header "Content-Type" should be equal to "application/json" + And the JSON node "errors[0].extensions.status" should be equal to 403 + And the JSON node "errors[0].extensions.category" should be equal to user And the JSON node "errors[0].message" should be equal to "Access Denied." Scenario: A user can retrieve an item they owns @@ -186,6 +196,8 @@ Feature: Authorization checking Then the response status code should be 200 And the response should be in JSON And the header "Content-Type" should be equal to "application/json" + And the JSON node "errors[0].extensions.status" should be equal to 403 + And the JSON node "errors[0].extensions.category" should be equal to user And the JSON node "errors[0].message" should be equal to "Access Denied." Scenario: A user can update an item they owns and transfer it diff --git a/features/graphql/introspection.feature b/features/graphql/introspection.feature index 0c093266985..62c34ae86e2 100644 --- a/features/graphql/introspection.feature +++ b/features/graphql/introspection.feature @@ -3,9 +3,11 @@ Feature: GraphQL introspection support @createSchema Scenario: Execute an empty GraphQL query When I send a "GET" request to "/graphql" - Then the response status code should be 400 + Then the response status code should be 200 And the response should be in JSON And the header "Content-Type" should be equal to "application/json" + And the JSON node "errors[0].extensions.status" should be equal to 400 + And the JSON node "errors[0].extensions.category" should be equal to user And the JSON node "errors[0].message" should be equal to "GraphQL query is not valid." Scenario: Introspect the GraphQL schema diff --git a/features/graphql/mutation.feature b/features/graphql/mutation.feature index ac8a8f36520..0baa2ce0b10 100644 --- a/features/graphql/mutation.feature +++ b/features/graphql/mutation.feature @@ -674,7 +674,11 @@ Feature: GraphQL mutation support Then the response status code should be 200 And the response should be in JSON And the header "Content-Type" should be equal to "application/json" + And the JSON node "errors[0].extensions.status" should be equal to "400" And the JSON node "errors[0].message" should be equal to "name: This value should not be blank." + And the JSON node "errors[0].extensions.violations" should exist + And the JSON node "errors[0].extensions.violations[0].path" should be equal to "name" + And the JSON node "errors[0].extensions.violations[0].message" should be equal to "This value should not be blank." Scenario: Execute a custom mutation Given there are 1 dummyCustomMutation objects diff --git a/src/Bridge/Symfony/Bundle/Resources/config/graphql.xml b/src/Bridge/Symfony/Bundle/Resources/config/graphql.xml index 07617218bf5..117b1ab8f87 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/graphql.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/graphql.xml @@ -167,6 +167,7 @@ + %kernel.debug% %api_platform.graphql.graphiql.enabled% %api_platform.graphql.graphql_playground.enabled% @@ -217,6 +218,22 @@ + + + + + + + + + + + + + + + + diff --git a/src/GraphQl/Action/EntrypointAction.php b/src/GraphQl/Action/EntrypointAction.php index 18b00be2e72..70785191e6a 100644 --- a/src/GraphQl/Action/EntrypointAction.php +++ b/src/GraphQl/Action/EntrypointAction.php @@ -17,16 +17,18 @@ use ApiPlatform\Core\GraphQl\Type\SchemaBuilderInterface; use GraphQL\Error\Debug; use GraphQL\Error\Error; -use GraphQL\Error\UserError; use GraphQL\Executor\ExecutionResult; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; /** * GraphQL API entrypoint. * + * @experimental + * * @author Alan Poulain */ final class EntrypointAction @@ -35,17 +37,19 @@ final class EntrypointAction private $executor; private $graphiQlAction; private $graphQlPlaygroundAction; + private $normalizer; private $debug; private $graphiqlEnabled; private $graphQlPlaygroundEnabled; private $defaultIde; - public function __construct(SchemaBuilderInterface $schemaBuilder, ExecutorInterface $executor, GraphiQlAction $graphiQlAction, GraphQlPlaygroundAction $graphQlPlaygroundAction, bool $debug = false, bool $graphiqlEnabled = false, bool $graphQlPlaygroundEnabled = false, $defaultIde = false) + public function __construct(SchemaBuilderInterface $schemaBuilder, ExecutorInterface $executor, GraphiQlAction $graphiQlAction, GraphQlPlaygroundAction $graphQlPlaygroundAction, NormalizerInterface $normalizer, bool $debug = false, bool $graphiqlEnabled = false, bool $graphQlPlaygroundEnabled = false, $defaultIde = false) { $this->schemaBuilder = $schemaBuilder; $this->executor = $executor; $this->graphiQlAction = $graphiQlAction; $this->graphQlPlaygroundAction = $graphQlPlaygroundAction; + $this->normalizer = $normalizer; $this->debug = $debug ? Debug::INCLUDE_DEBUG_MESSAGE | Debug::INCLUDE_TRACE : false; $this->graphiqlEnabled = $graphiqlEnabled; $this->graphQlPlaygroundEnabled = $graphQlPlaygroundEnabled; @@ -54,29 +58,28 @@ public function __construct(SchemaBuilderInterface $schemaBuilder, ExecutorInter public function __invoke(Request $request): Response { - if ($request->isMethod('GET') && 'html' === $request->getRequestFormat()) { - if ('graphiql' === $this->defaultIde && $this->graphiqlEnabled) { - return ($this->graphiQlAction)($request); - } + try { + if ($request->isMethod('GET') && 'html' === $request->getRequestFormat()) { + if ('graphiql' === $this->defaultIde && $this->graphiqlEnabled) { + return ($this->graphiQlAction)($request); + } - if ('graphql-playground' === $this->defaultIde && $this->graphQlPlaygroundEnabled) { - return ($this->graphQlPlaygroundAction)($request); + if ('graphql-playground' === $this->defaultIde && $this->graphQlPlaygroundEnabled) { + return ($this->graphQlPlaygroundAction)($request); + } } - } - try { [$query, $operation, $variables] = $this->parseRequest($request); if (null === $query) { throw new BadRequestHttpException('GraphQL query is not valid.'); } - $executionResult = $this->executor->executeQuery($this->schemaBuilder->getSchema(), $query, null, null, $variables, $operation); - } catch (BadRequestHttpException $e) { - $exception = new UserError($e->getMessage(), 0, $e); - - return $this->buildExceptionResponse($exception, Response::HTTP_BAD_REQUEST); - } catch (\Exception $e) { - return $this->buildExceptionResponse($e, Response::HTTP_OK); + $executionResult = $this->executor + ->executeQuery($this->schemaBuilder->getSchema(), $query, null, null, $variables, $operation) + ->setErrorFormatter([$this->normalizer, 'normalize']); + } catch (\Exception $exception) { + $executionResult = (new ExecutionResult(null, [new Error($exception->getMessage(), null, null, null, null, $exception)])) + ->setErrorFormatter([$this->normalizer, 'normalize']); } return new JsonResponse($executionResult->toArray($this->debug)); @@ -207,11 +210,4 @@ private function decodeVariables(string $variables): array return $variables; } - - private function buildExceptionResponse(\Exception $e, int $statusCode): JsonResponse - { - $executionResult = new ExecutionResult(null, [new Error($e->getMessage(), null, null, null, null, $e)]); - - return new JsonResponse($executionResult->toArray($this->debug), $statusCode); - } } diff --git a/src/GraphQl/Action/GraphQlPlaygroundAction.php b/src/GraphQl/Action/GraphQlPlaygroundAction.php index 295064451f6..614b33ab49f 100644 --- a/src/GraphQl/Action/GraphQlPlaygroundAction.php +++ b/src/GraphQl/Action/GraphQlPlaygroundAction.php @@ -22,6 +22,8 @@ /** * GraphQL Playground entrypoint. * + * @experimental + * * @author Alan Poulain */ final class GraphQlPlaygroundAction diff --git a/src/GraphQl/Action/GraphiQlAction.php b/src/GraphQl/Action/GraphiQlAction.php index 13c24532fce..1a749cbba25 100644 --- a/src/GraphQl/Action/GraphiQlAction.php +++ b/src/GraphQl/Action/GraphiQlAction.php @@ -22,6 +22,8 @@ /** * GraphiQL entrypoint. * + * @experimental + * * @author Alan Poulain */ final class GraphiQlAction diff --git a/src/GraphQl/Resolver/Factory/ItemMutationResolverFactory.php b/src/GraphQl/Resolver/Factory/ItemMutationResolverFactory.php index fe5948b990a..5214bfa7232 100644 --- a/src/GraphQl/Resolver/Factory/ItemMutationResolverFactory.php +++ b/src/GraphQl/Resolver/Factory/ItemMutationResolverFactory.php @@ -24,7 +24,6 @@ use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use ApiPlatform\Core\Util\ClassInfoTrait; use ApiPlatform\Core\Util\CloneTrait; -use GraphQL\Error\Error; use GraphQL\Type\Definition\ResolveInfo; use Psr\Container\ContainerInterface; @@ -106,7 +105,7 @@ public function __invoke(?string $resourceClass = null, ?string $rootClass = nul $mutationResolver = $this->mutationResolverLocator->get($mutationResolverId); $item = $mutationResolver($item, $resolverContext); if (null !== $item && $resourceClass !== $itemClass = $this->getObjectClass($item)) { - throw Error::createLocatedError(sprintf('Custom mutation resolver "%s" has to return an item of class %s but returned an item of class %s.', $mutationResolverId, $resourceMetadata->getShortName(), (new \ReflectionClass($itemClass))->getShortName()), $info->fieldNodes, $info->path); + throw new \LogicException(sprintf('Custom mutation resolver "%s" has to return an item of class %s but returned an item of class %s.', $mutationResolverId, $resourceMetadata->getShortName(), (new \ReflectionClass($itemClass))->getShortName())); } } diff --git a/src/GraphQl/Resolver/Factory/ItemResolverFactory.php b/src/GraphQl/Resolver/Factory/ItemResolverFactory.php index ed00054e537..d7ab7387666 100644 --- a/src/GraphQl/Resolver/Factory/ItemResolverFactory.php +++ b/src/GraphQl/Resolver/Factory/ItemResolverFactory.php @@ -21,7 +21,6 @@ use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use ApiPlatform\Core\Util\ClassInfoTrait; use ApiPlatform\Core\Util\CloneTrait; -use GraphQL\Error\Error; use GraphQL\Type\Definition\ResolveInfo; use Psr\Container\ContainerInterface; @@ -72,7 +71,7 @@ public function __invoke(?string $resourceClass = null, ?string $rootClass = nul throw new \LogicException('Item from read stage should be a nullable object.'); } - $resourceClass = $this->getResourceClass($item, $resourceClass, $info); + $resourceClass = $this->getResourceClass($item, $resourceClass); $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass); $queryResolverId = $resourceMetadata->getGraphqlAttribute($operationName, 'item_query'); @@ -80,7 +79,7 @@ public function __invoke(?string $resourceClass = null, ?string $rootClass = nul /** @var QueryItemResolverInterface $queryResolver */ $queryResolver = $this->queryResolverLocator->get($queryResolverId); $item = $queryResolver($item, $resolverContext); - $resourceClass = $this->getResourceClass($item, $resourceClass, $info, sprintf('Custom query resolver "%s"', $queryResolverId).' has to return an item of class %s but returned an item of class %s.'); + $resourceClass = $this->getResourceClass($item, $resourceClass, sprintf('Custom query resolver "%s"', $queryResolverId).' has to return an item of class %s but returned an item of class %s.'); } ($this->securityStage)($resourceClass, $operationName, $resolverContext + [ @@ -102,13 +101,13 @@ public function __invoke(?string $resourceClass = null, ?string $rootClass = nul /** * @param object|null $item * - * @throws Error + * @throws \UnexpectedValueException */ - private function getResourceClass($item, ?string $resourceClass, ResolveInfo $info, string $errorMessage = 'Resolver only handles items of class %s but retrieved item is of class %s.'): string + private function getResourceClass($item, ?string $resourceClass, string $errorMessage = 'Resolver only handles items of class %s but retrieved item is of class %s.'): string { if (null === $item) { if (null === $resourceClass) { - throw Error::createLocatedError('Resource class cannot be determined.', $info->fieldNodes, $info->path); + throw new \UnexpectedValueException('Resource class cannot be determined.'); } return $resourceClass; @@ -121,7 +120,7 @@ private function getResourceClass($item, ?string $resourceClass, ResolveInfo $in } if ($resourceClass !== $itemClass) { - throw Error::createLocatedError(sprintf($errorMessage, (new \ReflectionClass($resourceClass))->getShortName(), (new \ReflectionClass($itemClass))->getShortName()), $info->fieldNodes, $info->path); + throw new \UnexpectedValueException(sprintf($errorMessage, (new \ReflectionClass($resourceClass))->getShortName(), (new \ReflectionClass($itemClass))->getShortName())); } return $resourceClass; diff --git a/src/GraphQl/Resolver/Stage/ReadStage.php b/src/GraphQl/Resolver/Stage/ReadStage.php index bfe4bde7ac1..3f16806e16b 100644 --- a/src/GraphQl/Resolver/Stage/ReadStage.php +++ b/src/GraphQl/Resolver/Stage/ReadStage.php @@ -21,8 +21,8 @@ use ApiPlatform\Core\GraphQl\Serializer\SerializerContextBuilderInterface; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use ApiPlatform\Core\Util\ClassInfoTrait; -use GraphQL\Error\Error; use GraphQL\Type\Definition\ResolveInfo; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; /** * Read stage of GraphQL resolvers. @@ -63,9 +63,6 @@ public function __invoke(?string $resourceClass, ?string $rootClass, string $ope } $args = $context['args']; - /** @var ResolveInfo $info */ - $info = $context['info']; - $normalizationContext = $this->serializerContextBuilder->create($resourceClass, $operationName, $context, true); if (!$context['is_collection']) { @@ -74,11 +71,11 @@ public function __invoke(?string $resourceClass, ?string $rootClass, string $ope if ($identifier && $context['is_mutation']) { if (null === $item) { - throw Error::createLocatedError(sprintf('Item "%s" not found.', $args['input']['id']), $info->fieldNodes, $info->path); + throw new NotFoundHttpException(sprintf('Item "%s" not found.', $args['input']['id'])); } if ($resourceClass !== $this->getObjectClass($item)) { - throw Error::createLocatedError(sprintf('Item "%s" did not match expected type "%s".', $args['input']['id'], $resourceMetadata->getShortName()), $info->fieldNodes, $info->path); + throw new \UnexpectedValueException(sprintf('Item "%s" did not match expected type "%s".', $args['input']['id'], $resourceMetadata->getShortName())); } } @@ -92,11 +89,13 @@ public function __invoke(?string $resourceClass, ?string $rootClass, string $ope $normalizationContext['filters'] = $this->getNormalizedFilters($args); $source = $context['source']; + /** @var ResolveInfo $info */ + $info = $context['info']; if (isset($source[$rootProperty = $info->fieldName], $source[ItemNormalizer::ITEM_IDENTIFIERS_KEY])) { $rootResolvedFields = $source[ItemNormalizer::ITEM_IDENTIFIERS_KEY]; $subresourceCollection = $this->getSubresource($rootClass, $rootResolvedFields, $rootProperty, $resourceClass, $normalizationContext, $operationName); if (!is_iterable($subresourceCollection)) { - throw new \UnexpectedValueException('Expected subresource collection to be iterable'); + throw new \UnexpectedValueException('Expected subresource collection to be iterable.'); } return $subresourceCollection; diff --git a/src/GraphQl/Resolver/Stage/SecurityPostDenormalizeStage.php b/src/GraphQl/Resolver/Stage/SecurityPostDenormalizeStage.php index d6ab052d085..23c7b4cd86d 100644 --- a/src/GraphQl/Resolver/Stage/SecurityPostDenormalizeStage.php +++ b/src/GraphQl/Resolver/Stage/SecurityPostDenormalizeStage.php @@ -15,8 +15,7 @@ use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use ApiPlatform\Core\Security\ResourceAccessCheckerInterface; -use GraphQL\Error\Error; -use GraphQL\Type\Definition\ResolveInfo; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; /** * Security post denormalize stage of GraphQL resolvers. @@ -61,8 +60,6 @@ public function __invoke(string $resourceClass, string $operationName, array $co return; } - /** @var ResolveInfo $info */ - $info = $context['info']; - throw Error::createLocatedError($resourceMetadata->getGraphqlAttribute($operationName, 'security_post_denormalize_message', 'Access Denied.'), $info->fieldNodes, $info->path); + throw new AccessDeniedHttpException($resourceMetadata->getGraphqlAttribute($operationName, 'security_post_denormalize_message', 'Access Denied.')); } } diff --git a/src/GraphQl/Resolver/Stage/SecurityStage.php b/src/GraphQl/Resolver/Stage/SecurityStage.php index b3afa035618..6297c6eba4d 100644 --- a/src/GraphQl/Resolver/Stage/SecurityStage.php +++ b/src/GraphQl/Resolver/Stage/SecurityStage.php @@ -15,8 +15,7 @@ use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use ApiPlatform\Core\Security\ResourceAccessCheckerInterface; -use GraphQL\Error\Error; -use GraphQL\Type\Definition\ResolveInfo; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; /** * Security stage of GraphQL resolvers. @@ -53,8 +52,6 @@ public function __invoke(string $resourceClass, string $operationName, array $co return; } - /** @var ResolveInfo $info */ - $info = $context['info']; - throw Error::createLocatedError($resourceMetadata->getGraphqlAttribute($operationName, 'security_message', 'Access Denied.'), $info->fieldNodes, $info->path); + throw new AccessDeniedHttpException($resourceMetadata->getGraphqlAttribute($operationName, 'security_message', 'Access Denied.')); } } diff --git a/src/GraphQl/Resolver/Stage/SerializeStage.php b/src/GraphQl/Resolver/Stage/SerializeStage.php index 8c1d6feff07..18e72b51a8e 100644 --- a/src/GraphQl/Resolver/Stage/SerializeStage.php +++ b/src/GraphQl/Resolver/Stage/SerializeStage.php @@ -18,8 +18,6 @@ use ApiPlatform\Core\GraphQl\Serializer\ItemNormalizer; use ApiPlatform\Core\GraphQl\Serializer\SerializerContextBuilderInterface; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; -use GraphQL\Error\Error; -use GraphQL\Type\Definition\ResolveInfo; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; /** @@ -72,8 +70,6 @@ public function __invoke($itemOrCollection, string $resourceClass, string $opera $normalizationContext = $this->serializerContextBuilder->create($resourceClass, $operationName, $context, true); $args = $context['args']; - /** @var ResolveInfo $info */ - $info = $context['info']; $data = null; if (!$isCollection) { @@ -96,7 +92,7 @@ public function __invoke($itemOrCollection, string $resourceClass, string $opera } if (null !== $data && !\is_array($data)) { - throw Error::createLocatedError('Expected serialized data to be a nullable array.', $info->fieldNodes, $info->path); + throw new \UnexpectedValueException('Expected serialized data to be a nullable array.'); } if ($isMutation) { @@ -109,16 +105,15 @@ public function __invoke($itemOrCollection, string $resourceClass, string $opera } /** - * @throws Error + * @throws \LogicException + * @throws \UnexpectedValueException */ private function serializePaginatedCollection(iterable $collection, array $normalizationContext, array $context): array { $args = $context['args']; - /** @var ResolveInfo $info */ - $info = $context['info']; if (!($collection instanceof PaginatorInterface)) { - throw Error::createLocatedError(sprintf('Collection returned by the collection data provider must implement %s', PaginatorInterface::class), $info->fieldNodes, $info->path); + throw new \LogicException(sprintf('Collection returned by the collection data provider must implement %s.', PaginatorInterface::class)); } $offset = 0; @@ -127,14 +122,14 @@ private function serializePaginatedCollection(iterable $collection, array $norma if (isset($args['after'])) { $after = base64_decode($args['after'], true); if (false === $after) { - throw Error::createLocatedError(sprintf('Cursor %s is invalid', $args['after']), $info->fieldNodes, $info->path); + throw new \UnexpectedValueException(sprintf('Cursor %s is invalid.', $args['after'])); } $offset = 1 + (int) $after; } if (isset($args['before'])) { $before = base64_decode($args['before'], true); if (false === $before) { - throw Error::createLocatedError(sprintf('Cursor %s is invalid', $args['before']), $info->fieldNodes, $info->path); + throw new \UnexpectedValueException(sprintf('Cursor %s is invalid.', $args['before'])); } $offset = (int) $before - $nbPageItems; } diff --git a/src/GraphQl/Resolver/Stage/ValidateStage.php b/src/GraphQl/Resolver/Stage/ValidateStage.php index 14d98e51ae0..3c46aea7746 100644 --- a/src/GraphQl/Resolver/Stage/ValidateStage.php +++ b/src/GraphQl/Resolver/Stage/ValidateStage.php @@ -14,10 +14,7 @@ namespace ApiPlatform\Core\GraphQl\Resolver\Stage; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; -use ApiPlatform\Core\Validator\Exception\ValidationException; use ApiPlatform\Core\Validator\ValidatorInterface; -use GraphQL\Error\Error; -use GraphQL\Type\Definition\ResolveInfo; /** * Validate stage of GraphQL resolvers. @@ -48,13 +45,6 @@ public function __invoke($object, string $resourceClass, string $operationName, } $validationGroups = $resourceMetadata->getGraphqlAttribute($operationName, 'validation_groups', null, true); - try { - $this->validator->validate($object, ['groups' => $validationGroups]); - } catch (ValidationException $e) { - /** @var ResolveInfo $info */ - $info = $context['info']; - - throw Error::createLocatedError($e->getMessage(), $info->fieldNodes, $info->path); - } + $this->validator->validate($object, ['groups' => $validationGroups]); } } diff --git a/src/GraphQl/Serializer/Exception/ErrorNormalizer.php b/src/GraphQl/Serializer/Exception/ErrorNormalizer.php new file mode 100644 index 00000000000..05b214f1c58 --- /dev/null +++ b/src/GraphQl/Serializer/Exception/ErrorNormalizer.php @@ -0,0 +1,44 @@ + + * + * 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\GraphQl\Serializer\Exception; + +use GraphQL\Error\Error; +use GraphQL\Error\FormattedError; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + +/** + * Normalize GraphQL error (fallback). + * + * @experimental + * + * @author Alan Poulain + */ +final class ErrorNormalizer implements NormalizerInterface +{ + /** + * {@inheritdoc} + */ + public function normalize($object, $format = null, array $context = []): array + { + return FormattedError::createFromException($object); + } + + /** + * {@inheritdoc} + */ + public function supportsNormalization($data, $format = null): bool + { + return $data instanceof Error; + } +} diff --git a/src/GraphQl/Serializer/Exception/HttpExceptionNormalizer.php b/src/GraphQl/Serializer/Exception/HttpExceptionNormalizer.php new file mode 100644 index 00000000000..061cfb3199e --- /dev/null +++ b/src/GraphQl/Serializer/Exception/HttpExceptionNormalizer.php @@ -0,0 +1,52 @@ + + * + * 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\GraphQl\Serializer\Exception; + +use GraphQL\Error\Error; +use GraphQL\Error\FormattedError; +use Symfony\Component\HttpKernel\Exception\HttpException; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + +/** + * Normalize HTTP exceptions. + * + * @experimental + * + * @author Alan Poulain + */ +final class HttpExceptionNormalizer implements NormalizerInterface +{ + /** + * {@inheritdoc} + */ + public function normalize($object, $format = null, array $context = []): array + { + /** @var HttpException */ + $httpException = $object->getPrevious(); + $error = FormattedError::createFromException($object); + $error['message'] = $httpException->getMessage(); + $error['extensions']['status'] = $statusCode = $httpException->getStatusCode(); + $error['extensions']['category'] = $statusCode < 500 ? 'user' : Error::CATEGORY_INTERNAL; + + return $error; + } + + /** + * {@inheritdoc} + */ + public function supportsNormalization($data, $format = null): bool + { + return $data instanceof Error && $data->getPrevious() instanceof HttpException; + } +} diff --git a/src/GraphQl/Serializer/Exception/RuntimeExceptionNormalizer.php b/src/GraphQl/Serializer/Exception/RuntimeExceptionNormalizer.php new file mode 100644 index 00000000000..61bc85fe7e8 --- /dev/null +++ b/src/GraphQl/Serializer/Exception/RuntimeExceptionNormalizer.php @@ -0,0 +1,49 @@ + + * + * 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\GraphQl\Serializer\Exception; + +use GraphQL\Error\Error; +use GraphQL\Error\FormattedError; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + +/** + * Normalize runtime exceptions to have the right message in production mode. + * + * @experimental + * + * @author Alan Poulain + */ +final class RuntimeExceptionNormalizer implements NormalizerInterface +{ + /** + * {@inheritdoc} + */ + public function normalize($object, $format = null, array $context = []): array + { + /** @var \RuntimeException */ + $runtimeException = $object->getPrevious(); + $error = FormattedError::createFromException($object); + $error['message'] = $runtimeException->getMessage(); + + return $error; + } + + /** + * {@inheritdoc} + */ + public function supportsNormalization($data, $format = null): bool + { + return $data instanceof Error && $data->getPrevious() instanceof \RuntimeException; + } +} diff --git a/src/GraphQl/Serializer/Exception/ValidationExceptionNormalizer.php b/src/GraphQl/Serializer/Exception/ValidationExceptionNormalizer.php new file mode 100644 index 00000000000..d62986ef561 --- /dev/null +++ b/src/GraphQl/Serializer/Exception/ValidationExceptionNormalizer.php @@ -0,0 +1,64 @@ + + * + * 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\GraphQl\Serializer\Exception; + +use ApiPlatform\Core\Bridge\Symfony\Validator\Exception\ValidationException; +use GraphQL\Error\Error; +use GraphQL\Error\FormattedError; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; +use Symfony\Component\Validator\ConstraintViolation; + +/** + * Normalize validation exceptions. + * + * @experimental + * + * @author Mahmood Bazdar + * @author Alan Poulain + */ +final class ValidationExceptionNormalizer implements NormalizerInterface +{ + /** + * {@inheritdoc} + */ + public function normalize($object, $format = null, array $context = []): array + { + /** @var ValidationException */ + $validationException = $object->getPrevious(); + $error = FormattedError::createFromException($object); + $error['message'] = $validationException->getMessage(); + $error['extensions']['status'] = Response::HTTP_BAD_REQUEST; + $error['extensions']['category'] = 'user'; + $error['extensions']['violations'] = []; + + /** @var ConstraintViolation $violation */ + foreach ($validationException->getConstraintViolationList() as $violation) { + $error['extensions']['violations'][] = [ + 'path' => $violation->getPropertyPath(), + 'message' => $violation->getMessage(), + ]; + } + + return $error; + } + + /** + * {@inheritdoc} + */ + public function supportsNormalization($data, $format = null): bool + { + return $data instanceof Error && $data->getPrevious() instanceof ValidationException; + } +} diff --git a/src/GraphQl/Serializer/ItemNormalizer.php b/src/GraphQl/Serializer/ItemNormalizer.php index 12fa55c1f23..42c5d5a9490 100644 --- a/src/GraphQl/Serializer/ItemNormalizer.php +++ b/src/GraphQl/Serializer/ItemNormalizer.php @@ -73,7 +73,7 @@ public function normalize($object, $format = null, array $context = []) $data = parent::normalize($object, $format, $context); if (!\is_array($data)) { - throw new UnexpectedValueException('Expected data to be an array'); + throw new UnexpectedValueException('Expected data to be an array.'); } $data[self::ITEM_RESOURCE_CLASS_KEY] = $this->getObjectClass($object); diff --git a/src/GraphQl/Serializer/ObjectNormalizer.php b/src/GraphQl/Serializer/ObjectNormalizer.php index 2fd5651fe95..56620d5e1d7 100644 --- a/src/GraphQl/Serializer/ObjectNormalizer.php +++ b/src/GraphQl/Serializer/ObjectNormalizer.php @@ -73,7 +73,7 @@ public function normalize($object, $format = null, array $context = []) $data = $this->decorated->normalize($object, $format, $context); if (!\is_array($data)) { - throw new UnexpectedValueException('Expected data to be an array'); + throw new UnexpectedValueException('Expected data to be an array.'); } if (!isset($originalResource)) { diff --git a/src/GraphQl/Type/TypeBuilder.php b/src/GraphQl/Type/TypeBuilder.php index d13beacb973..66b6b23fcc3 100644 --- a/src/GraphQl/Type/TypeBuilder.php +++ b/src/GraphQl/Type/TypeBuilder.php @@ -77,7 +77,7 @@ public function getResourceObjectType(?string $resourceClass, ResourceMetadata $ if ($this->typesContainer->has($shortName)) { $resourceObjectType = $this->typesContainer->get($shortName); if (!($resourceObjectType instanceof ObjectType || $resourceObjectType instanceof NonNull)) { - throw new \UnexpectedValueException(sprintf('Expected GraphQL type "%s" to be %s.', $shortName, implode('|', [ObjectType::class, NonNull::class]))); + throw new \LogicException(sprintf('Expected GraphQL type "%s" to be %s.', $shortName, implode('|', [ObjectType::class, NonNull::class]))); } return $resourceObjectType; @@ -137,7 +137,7 @@ public function getNodeInterface(): InterfaceType if ($this->typesContainer->has('Node')) { $nodeInterface = $this->typesContainer->get('Node'); if (!$nodeInterface instanceof InterfaceType) { - throw new \UnexpectedValueException(sprintf('Expected GraphQL type "Node" to be %s.', InterfaceType::class)); + throw new \LogicException(sprintf('Expected GraphQL type "Node" to be %s.', InterfaceType::class)); } return $nodeInterface; diff --git a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php index 40aff578ba1..84e0867ce47 100644 --- a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php +++ b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php @@ -367,6 +367,10 @@ public function testDisableGraphQl() $containerBuilderProphecy->setDefinition('api_platform.graphql.type_converter', Argument::type(Definition::class))->shouldNotBeCalled(); $containerBuilderProphecy->setDefinition('api_platform.graphql.query_resolver_locator', Argument::type(Definition::class))->shouldNotBeCalled(); $containerBuilderProphecy->setDefinition('api_platform.graphql.mutation_resolver_locator', Argument::type(Definition::class))->shouldNotBeCalled(); + $containerBuilderProphecy->setDefinition('api_platform.graphql.normalizer.error', Argument::type(Definition::class))->shouldNotBeCalled(); + $containerBuilderProphecy->setDefinition('api_platform.graphql.normalizer.validation_exception', Argument::type(Definition::class))->shouldNotBeCalled(); + $containerBuilderProphecy->setDefinition('api_platform.graphql.normalizer.http_exception', Argument::type(Definition::class))->shouldNotBeCalled(); + $containerBuilderProphecy->setDefinition('api_platform.graphql.normalizer.runtime_exception', Argument::type(Definition::class))->shouldNotBeCalled(); $containerBuilderProphecy->setDefinition('api_platform.graphql.command.export_command', Argument::type(Definition::class))->shouldNotBeCalled(); $containerBuilderProphecy->setParameter('api_platform.graphql.enabled', true)->shouldNotBeCalled(); $containerBuilderProphecy->setParameter('api_platform.graphql.enabled', false)->shouldBeCalled(); @@ -1183,6 +1187,10 @@ private function getBaseContainerBuilderProphecy(array $doctrineIntegrationsToLo 'api_platform.graphql.resolver.stage.write', 'api_platform.graphql.resolver.stage.validate', 'api_platform.graphql.resolver.resource_field', + 'api_platform.graphql.normalizer.error', + 'api_platform.graphql.normalizer.validation_exception', + 'api_platform.graphql.normalizer.http_exception', + 'api_platform.graphql.normalizer.runtime_exception', 'api_platform.graphql.iterable_type', 'api_platform.graphql.upload_type', 'api_platform.graphql.type_locator', diff --git a/tests/GraphQl/Action/EntrypointActionTest.php b/tests/GraphQl/Action/EntrypointActionTest.php index 74ffd74aad0..9da2d7edc7f 100644 --- a/tests/GraphQl/Action/EntrypointActionTest.php +++ b/tests/GraphQl/Action/EntrypointActionTest.php @@ -17,6 +17,8 @@ use ApiPlatform\Core\GraphQl\Action\GraphiQlAction; use ApiPlatform\Core\GraphQl\Action\GraphQlPlaygroundAction; use ApiPlatform\Core\GraphQl\ExecutorInterface; +use ApiPlatform\Core\GraphQl\Serializer\Exception\ErrorNormalizer; +use ApiPlatform\Core\GraphQl\Serializer\Exception\HttpExceptionNormalizer; use ApiPlatform\Core\GraphQl\Type\SchemaBuilderInterface; use GraphQL\Executor\ExecutionResult; use GraphQL\Type\Schema; @@ -27,6 +29,7 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\RouterInterface; +use Symfony\Component\Serializer\Serializer; use Twig\Environment as TwigEnvironment; /** @@ -37,7 +40,7 @@ class EntrypointActionTest extends TestCase /** * Hack to avoid transient failing test because of Date header. */ - private function assertEqualsWithoutDateHeader(JsonResponse $expected, Response $actual) + private function assertEqualsWithoutDateHeader(JsonResponse $expected, Response $actual): void { $expected->headers->remove('Date'); $actual->headers->remove('Date'); @@ -53,7 +56,7 @@ public function testGetHtmlAction(): void $this->assertInstanceOf(Response::class, $mockedEntrypoint($request)); } - public function testGetAction() + public function testGetAction(): void { $request = new Request(['query' => 'graphqlQuery', 'variables' => '["graphqlVariable"]', 'operation' => 'graphqlOperationName']); $request->setRequestFormat('json'); @@ -62,7 +65,7 @@ public function testGetAction() $this->assertEqualsWithoutDateHeader(new JsonResponse(['GraphQL']), $mockedEntrypoint($request)); } - public function testPostRawAction() + public function testPostRawAction(): void { $request = new Request(['variables' => '["graphqlVariable"]', 'operation' => 'graphqlOperationName'], [], [], [], [], [], 'graphqlQuery'); $request->setFormat('graphql', 'application/graphql'); @@ -73,7 +76,7 @@ public function testPostRawAction() $this->assertEqualsWithoutDateHeader(new JsonResponse(['GraphQL']), $mockedEntrypoint($request)); } - public function testPostJsonAction() + public function testPostJsonAction(): void { $request = new Request([], [], [], [], [], [], '{"query": "graphqlQuery", "variables": "[\"graphqlVariable\"]", "operation": "graphqlOperationName"}'); $request->setMethod('POST'); @@ -86,7 +89,7 @@ public function testPostJsonAction() /** * @dataProvider multipartRequestProvider */ - public function testMultipartRequestAction(?string $operations, ?string $map, array $files, array $variables, Response $expectedResponse) + public function testMultipartRequestAction(?string $operations, ?string $map, array $files, array $variables, Response $expectedResponse): void { $requestParams = []; if ($operations) { @@ -144,82 +147,82 @@ public function multipartRequestProvider(): array '{"file": ["variables.file"]}', ['file' => $file], ['file' => $file], - new Response('{"errors":[{"message":"GraphQL multipart request does not respect the specification.","extensions":{"category":"user"}}]}'), + new Response('{"errors":[{"message":"GraphQL multipart request does not respect the specification.","extensions":{"category":"user","status":400}}]}'), ], 'upload without providing map' => [ '{"query": "graphqlQuery", "variables": {"file": null}, "operation": "graphqlOperationName"}', null, ['file' => $file], ['file' => null], - new Response('{"errors":[{"message":"GraphQL multipart request does not respect the specification.","extensions":{"category":"user"}}]}'), + new Response('{"errors":[{"message":"GraphQL multipart request does not respect the specification.","extensions":{"category":"user","status":400}}]}'), ], 'upload with invalid json' => [ '{invalid}', '{"file": ["file"]}', ['file' => $file], ['file' => null], - new Response('{"errors":[{"message":"GraphQL data is not valid JSON.","extensions":{"category":"user"}}]}'), + new Response('{"errors":[{"message":"GraphQL data is not valid JSON.","extensions":{"category":"user","status":400}}]}'), ], 'upload with invalid map JSON' => [ '{"query": "graphqlQuery", "variables": {"file": null}, "operation": "graphqlOperationName"}', '{invalid}', ['file' => $file], ['file' => null], - new Response('{"errors":[{"message":"GraphQL multipart request map is not valid JSON.","extensions":{"category":"user"}}]}'), + new Response('{"errors":[{"message":"GraphQL multipart request map is not valid JSON.","extensions":{"category":"user","status":400}}]}'), ], 'upload with no file' => [ '{"query": "graphqlQuery", "variables": {"file": null}, "operation": "graphqlOperationName"}', '{"file": ["file"]}', [], ['file' => null], - new Response('{"errors":[{"message":"GraphQL multipart request file has not been sent correctly.","extensions":{"category":"user"}}]}'), + new Response('{"errors":[{"message":"GraphQL multipart request file has not been sent correctly.","extensions":{"category":"user","status":400}}]}'), ], 'upload with wrong map' => [ '{"query": "graphqlQuery", "variables": {"file": null}, "operation": "graphqlOperationName"}', '{"file": ["file"]}', ['file' => $file], ['file' => null], - new Response('{"errors":[{"message":"GraphQL multipart request path in map is invalid.","extensions":{"category":"user"}}]}'), + new Response('{"errors":[{"message":"GraphQL multipart request path in map is invalid.","extensions":{"category":"user","status":400}}]}'), ], 'upload when variable path does not exist' => [ '{"query": "graphqlQuery", "variables": {"file": null}, "operation": "graphqlOperationName"}', '{"file": ["variables.wrong"]}', ['file' => $file], ['file' => null], - new Response('{"errors":[{"message":"GraphQL multipart request path in map does not match the variables.","extensions":{"category":"user"}}]}'), + new Response('{"errors":[{"message":"GraphQL multipart request path in map does not match the variables.","extensions":{"category":"user","status":400}}]}'), ], ]; } - public function testBadContentTypePostAction() + public function testBadContentTypePostAction(): void { $request = new Request(); $request->setMethod('POST'); $request->headers->set('Content-Type', 'application/xml'); $mockedEntrypoint = $this->getEntrypointAction(); - $this->assertEquals(400, $mockedEntrypoint($request)->getStatusCode()); - $this->assertEquals('{"errors":[{"message":"GraphQL query is not valid.","extensions":{"category":"user"}}]}', $mockedEntrypoint($request)->getContent()); + $this->assertEquals(200, $mockedEntrypoint($request)->getStatusCode()); + $this->assertEquals('{"errors":[{"message":"GraphQL query is not valid.","extensions":{"category":"user","status":400}}]}', $mockedEntrypoint($request)->getContent()); } - public function testBadMethodAction() + public function testBadMethodAction(): void { $request = new Request(); $request->setMethod('PUT'); $mockedEntrypoint = $this->getEntrypointAction(); - $this->assertEquals(400, $mockedEntrypoint($request)->getStatusCode()); - $this->assertEquals('{"errors":[{"message":"GraphQL query is not valid.","extensions":{"category":"user"}}]}', $mockedEntrypoint($request)->getContent()); + $this->assertEquals(200, $mockedEntrypoint($request)->getStatusCode()); + $this->assertEquals('{"errors":[{"message":"GraphQL query is not valid.","extensions":{"category":"user","status":400}}]}', $mockedEntrypoint($request)->getContent()); } - public function testBadVariablesAction() + public function testBadVariablesAction(): void { $request = new Request(['query' => 'graphqlQuery', 'variables' => 'graphqlVariable', 'operation' => 'graphqlOperationName']); $request->setRequestFormat('json'); $mockedEntrypoint = $this->getEntrypointAction(); - $this->assertEquals(400, $mockedEntrypoint($request)->getStatusCode()); - $this->assertEquals('{"errors":[{"message":"GraphQL variables are not valid JSON.","extensions":{"category":"user"}}]}', $mockedEntrypoint($request)->getContent()); + $this->assertEquals(200, $mockedEntrypoint($request)->getStatusCode()); + $this->assertEquals('{"errors":[{"message":"GraphQL variables are not valid JSON.","extensions":{"category":"user","status":400}}]}', $mockedEntrypoint($request)->getContent()); } private function getEntrypointAction(array $variables = ['graphqlVariable']): EntrypointAction @@ -228,8 +231,14 @@ private function getEntrypointAction(array $variables = ['graphqlVariable']): En $schemaBuilderProphecy = $this->prophesize(SchemaBuilderInterface::class); $schemaBuilderProphecy->getSchema()->willReturn($schema->reveal()); + $normalizer = new Serializer([ + new HttpExceptionNormalizer(), + new ErrorNormalizer(), + ]); + $executionResultProphecy = $this->prophesize(ExecutionResult::class); $executionResultProphecy->toArray(false)->willReturn(['GraphQL']); + $executionResultProphecy->setErrorFormatter([$normalizer, 'normalize'])->willReturn($executionResultProphecy); $executorProphecy = $this->prophesize(ExecutorInterface::class); $executorProphecy->executeQuery(Argument::is($schema->reveal()), 'graphqlQuery', null, null, $variables, 'graphqlOperationName')->willReturn($executionResultProphecy->reveal()); @@ -239,6 +248,6 @@ private function getEntrypointAction(array $variables = ['graphqlVariable']): En $graphiQlAction = new GraphiQlAction($twigProphecy->reveal(), $routerProphecy->reveal(), true); $graphQlPlaygroundAction = new GraphQlPlaygroundAction($twigProphecy->reveal(), $routerProphecy->reveal(), true); - return new EntrypointAction($schemaBuilderProphecy->reveal(), $executorProphecy->reveal(), $graphiQlAction, $graphQlPlaygroundAction, false, true, true, 'graphiql'); + return new EntrypointAction($schemaBuilderProphecy->reveal(), $executorProphecy->reveal(), $graphiQlAction, $graphQlPlaygroundAction, $normalizer, false, true, true, 'graphiql'); } } diff --git a/tests/GraphQl/Resolver/Factory/ItemMutationResolverFactoryTest.php b/tests/GraphQl/Resolver/Factory/ItemMutationResolverFactoryTest.php index 8f9f5ae178f..ce283a65e33 100644 --- a/tests/GraphQl/Resolver/Factory/ItemMutationResolverFactoryTest.php +++ b/tests/GraphQl/Resolver/Factory/ItemMutationResolverFactoryTest.php @@ -24,7 +24,6 @@ use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy; -use GraphQL\Error\Error; use GraphQL\Type\Definition\ResolveInfo; use PHPUnit\Framework\TestCase; use Prophecy\Argument; @@ -321,7 +320,7 @@ public function testResolveCustomBadItem(): void return $customItem; }); - $this->expectException(Error::class); + $this->expectException(\LogicException::class); $this->expectExceptionMessage('Custom mutation resolver "query_resolver_id" has to return an item of class shortName but returned an item of class Dummy.'); ($this->itemMutationResolverFactory)($resourceClass, $rootClass, $operationName)($source, $args, null, $info); diff --git a/tests/GraphQl/Resolver/Factory/ItemResolverFactoryTest.php b/tests/GraphQl/Resolver/Factory/ItemResolverFactoryTest.php index 1093daf904d..bae61f2a5b2 100644 --- a/tests/GraphQl/Resolver/Factory/ItemResolverFactoryTest.php +++ b/tests/GraphQl/Resolver/Factory/ItemResolverFactoryTest.php @@ -21,7 +21,6 @@ use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy; -use GraphQL\Error\Error; use GraphQL\Type\Definition\ResolveInfo; use PHPUnit\Framework\TestCase; use Psr\Container\ContainerInterface; @@ -148,7 +147,7 @@ public function testResolveNoResourceNoItem(): void $readStageItem = null; $this->readStageProphecy->__invoke($resourceClass, $rootClass, $operationName, $resolverContext)->shouldBeCalled()->willReturn($readStageItem); - $this->expectException(Error::class); + $this->expectException(\UnexpectedValueException::class); $this->expectExceptionMessage('Resource class cannot be determined.'); ($this->itemResolverFactory)($resourceClass, $rootClass, $operationName)($source, $args, null, $info); @@ -167,7 +166,7 @@ public function testResolveBadItem(): void $readStageItem = new \stdClass(); $this->readStageProphecy->__invoke($resourceClass, $rootClass, $operationName, $resolverContext)->shouldBeCalled()->willReturn($readStageItem); - $this->expectException(Error::class); + $this->expectException(\UnexpectedValueException::class); $this->expectExceptionMessage('Resolver only handles items of class Dummy but retrieved item is of class stdClass.'); ($this->itemResolverFactory)($resourceClass, $rootClass, $operationName)($source, $args, null, $info); @@ -236,7 +235,7 @@ public function testResolveCustomBadItem(): void return $customItem; }); - $this->expectException(Error::class); + $this->expectException(\UnexpectedValueException::class); $this->expectExceptionMessage('Custom query resolver "query_resolver_id" has to return an item of class stdClass but returned an item of class Dummy.'); ($this->itemResolverFactory)($resourceClass, $rootClass, $operationName)($source, $args, null, $info); diff --git a/tests/GraphQl/Resolver/Stage/ReadStageTest.php b/tests/GraphQl/Resolver/Stage/ReadStageTest.php index d63e39e2fbd..fce8e9e6c65 100644 --- a/tests/GraphQl/Resolver/Stage/ReadStageTest.php +++ b/tests/GraphQl/Resolver/Stage/ReadStageTest.php @@ -22,9 +22,9 @@ use ApiPlatform\Core\GraphQl\Serializer\SerializerContextBuilderInterface; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; -use GraphQL\Error\Error; use GraphQL\Type\Definition\ResolveInfo; use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; /** * @author Alan Poulain @@ -175,8 +175,8 @@ public function itemMutationProvider(): array return [ 'no identifier' => ['myResource', null, $item, false, null], 'identifier' => ['stdClass', 'identifier', $item, false, $item], - 'identifier bad item' => ['myResource', 'identifier', $item, false, $item, Error::class, 'Item "identifier" did not match expected type "shortName".'], - 'identifier not found' => ['myResource', 'identifier_not_found', $item, true, null, Error::class, 'Item "identifier_not_found" not found.'], + 'identifier bad item' => ['myResource', 'identifier', $item, false, $item, \UnexpectedValueException::class, 'Item "identifier" did not match expected type "shortName".'], + 'identifier not found' => ['myResource', 'identifier_not_found', $item, true, null, NotFoundHttpException::class, 'Item "identifier_not_found" not found.'], ]; } diff --git a/tests/GraphQl/Resolver/Stage/SecurityPostDenormalizeStageTest.php b/tests/GraphQl/Resolver/Stage/SecurityPostDenormalizeStageTest.php index 301963c836a..1595e10e438 100644 --- a/tests/GraphQl/Resolver/Stage/SecurityPostDenormalizeStageTest.php +++ b/tests/GraphQl/Resolver/Stage/SecurityPostDenormalizeStageTest.php @@ -17,10 +17,10 @@ use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; use ApiPlatform\Core\Security\ResourceAccessCheckerInterface; -use GraphQL\Error\Error; use GraphQL\Type\Definition\ResolveInfo; use PHPUnit\Framework\TestCase; use Prophecy\Argument; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; /** * @author Alan Poulain @@ -107,7 +107,7 @@ public function testNotGranted(): void $info = $this->prophesize(ResolveInfo::class)->reveal(); - $this->expectException(Error::class); + $this->expectException(AccessDeniedHttpException::class); $this->expectExceptionMessage('Access Denied.'); ($this->securityPostDenormalizeStage)($resourceClass, 'item_query', [ diff --git a/tests/GraphQl/Resolver/Stage/SecurityStageTest.php b/tests/GraphQl/Resolver/Stage/SecurityStageTest.php index a49207679a2..e3d431a2c72 100644 --- a/tests/GraphQl/Resolver/Stage/SecurityStageTest.php +++ b/tests/GraphQl/Resolver/Stage/SecurityStageTest.php @@ -17,10 +17,10 @@ use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; use ApiPlatform\Core\Security\ResourceAccessCheckerInterface; -use GraphQL\Error\Error; use GraphQL\Type\Definition\ResolveInfo; use PHPUnit\Framework\TestCase; use Prophecy\Argument; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; /** * @author Alan Poulain @@ -88,7 +88,7 @@ public function testNotGranted(): void $info = $this->prophesize(ResolveInfo::class)->reveal(); - $this->expectException(Error::class); + $this->expectException(AccessDeniedHttpException::class); $this->expectExceptionMessage('Access Denied.'); ($this->securityStage)($resourceClass, 'item_query', [ diff --git a/tests/GraphQl/Resolver/Stage/SerializeStageTest.php b/tests/GraphQl/Resolver/Stage/SerializeStageTest.php index 640d15cfa90..0db5f9d5e9e 100644 --- a/tests/GraphQl/Resolver/Stage/SerializeStageTest.php +++ b/tests/GraphQl/Resolver/Stage/SerializeStageTest.php @@ -20,7 +20,6 @@ use ApiPlatform\Core\GraphQl\Serializer\SerializerContextBuilderInterface; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; -use GraphQL\Error\Error; use GraphQL\Type\Definition\ResolveInfo; use PHPUnit\Framework\TestCase; use Prophecy\Argument; @@ -140,13 +139,13 @@ public function testApplyCollectionWithPagination(iterable $collection, array $a public function applyCollectionWithPaginationProvider(): array { return [ - 'not paginator' => [[], [], null, Error::class, 'Collection returned by the collection data provider must implement ApiPlatform\Core\DataProvider\PaginatorInterface'], + 'not paginator' => [[], [], null, \LogicException::class, 'Collection returned by the collection data provider must implement ApiPlatform\Core\DataProvider\PaginatorInterface'], 'empty paginator' => [new ArrayPaginator([], 0, 0), [], ['totalCount' => 0., 'edges' => [], 'pageInfo' => ['startCursor' => null, 'endCursor' => null, 'hasNextPage' => false, 'hasPreviousPage' => false]]], 'paginator' => [new ArrayPaginator([new \stdClass(), new \stdClass(), new \stdClass()], 0, 2), [], ['totalCount' => 3., 'edges' => [['node' => ['normalized_item'], 'cursor' => 'MA=='], ['node' => ['normalized_item'], 'cursor' => 'MQ==']], 'pageInfo' => ['startCursor' => 'MA==', 'endCursor' => 'MQ==', 'hasNextPage' => true, 'hasPreviousPage' => false]]], 'paginator with after cursor' => [new ArrayPaginator([new \stdClass(), new \stdClass(), new \stdClass()], 1, 2), ['after' => 'MA=='], ['totalCount' => 3., 'edges' => [['node' => ['normalized_item'], 'cursor' => 'MQ=='], ['node' => ['normalized_item'], 'cursor' => 'Mg==']], 'pageInfo' => ['startCursor' => 'MQ==', 'endCursor' => 'Mg==', 'hasNextPage' => false, 'hasPreviousPage' => true]]], - 'paginator with bad after cursor' => [new ArrayPaginator([], 0, 0), ['after' => '-'], null, Error::class, 'Cursor - is invalid'], + 'paginator with bad after cursor' => [new ArrayPaginator([], 0, 0), ['after' => '-'], null, \UnexpectedValueException::class, 'Cursor - is invalid'], 'paginator with before cursor' => [new ArrayPaginator([new \stdClass(), new \stdClass(), new \stdClass()], 1, 1), ['before' => 'Mg=='], ['totalCount' => 3., 'edges' => [['node' => ['normalized_item'], 'cursor' => 'MQ==']], 'pageInfo' => ['startCursor' => 'MQ==', 'endCursor' => 'MQ==', 'hasNextPage' => false, 'hasPreviousPage' => true]]], - 'paginator with bad before cursor' => [new ArrayPaginator([], 0, 0), ['before' => '-'], null, Error::class, 'Cursor - is invalid'], + 'paginator with bad before cursor' => [new ArrayPaginator([], 0, 0), ['before' => '-'], null, \UnexpectedValueException::class, 'Cursor - is invalid'], 'paginator with last' => [new ArrayPaginator([new \stdClass(), new \stdClass(), new \stdClass()], 1, 2), ['last' => 2], ['totalCount' => 3., 'edges' => [['node' => ['normalized_item'], 'cursor' => 'MQ=='], ['node' => ['normalized_item'], 'cursor' => 'Mg==']], 'pageInfo' => ['startCursor' => 'MQ==', 'endCursor' => 'Mg==', 'hasNextPage' => false, 'hasPreviousPage' => true]]], ]; } @@ -163,7 +162,7 @@ public function testApplyBadNormalizedData(): void $this->normalizerProphecy->normalize(Argument::type('stdClass'), ItemNormalizer::FORMAT, $normalizationContext)->willReturn(new \stdClass()); - $this->expectException(Error::class); + $this->expectException(\UnexpectedValueException::class); $this->expectExceptionMessage('Expected serialized data to be a nullable array.'); ($this->createSerializeStage(false))(new \stdClass(), $resourceClass, $operationName, $context); diff --git a/tests/GraphQl/Resolver/Stage/ValidateStageTest.php b/tests/GraphQl/Resolver/Stage/ValidateStageTest.php index 7b064b75ef3..904bcd50213 100644 --- a/tests/GraphQl/Resolver/Stage/ValidateStageTest.php +++ b/tests/GraphQl/Resolver/Stage/ValidateStageTest.php @@ -18,7 +18,6 @@ use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; use ApiPlatform\Core\Validator\Exception\ValidationException; use ApiPlatform\Core\Validator\ValidatorInterface; -use GraphQL\Error\Error; use GraphQL\Type\Definition\ResolveInfo; use PHPUnit\Framework\TestCase; use Prophecy\Argument; @@ -92,7 +91,7 @@ public function testApplyNotValidated(): void $object = new \stdClass(); $this->validatorProphecy->validate($object, ['groups' => $validationGroups])->shouldBeCalled()->willThrow(new ValidationException()); - $this->expectException(Error::class); + $this->expectException(ValidationException::class); ($this->validateStage)($object, $resourceClass, $operationName, $context); } diff --git a/tests/GraphQl/Serializer/Exception/ErrorNormalizerTest.php b/tests/GraphQl/Serializer/Exception/ErrorNormalizerTest.php new file mode 100644 index 00000000000..ccceabc9e5a --- /dev/null +++ b/tests/GraphQl/Serializer/Exception/ErrorNormalizerTest.php @@ -0,0 +1,53 @@ + + * + * 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\Tests\GraphQl\Serializer\Exception; + +use ApiPlatform\Core\GraphQl\Serializer\Exception\ErrorNormalizer; +use GraphQL\Error\Error; +use PHPUnit\Framework\TestCase; + +/** + * @author Alan Poulain + */ +class ErrorNormalizerTest extends TestCase +{ + private $errorNormalizer; + + /** + * {@inheritdoc} + */ + protected function setUp(): void + { + parent::setUp(); + + $this->errorNormalizer = new ErrorNormalizer(); + } + + public function testNormalize(): void + { + $errorMessage = 'test message'; + $error = new Error($errorMessage); + + $normalizedError = $this->errorNormalizer->normalize($error); + $this->assertSame($errorMessage, $normalizedError['message']); + $this->assertSame(Error::CATEGORY_GRAPHQL, $normalizedError['extensions']['category']); + } + + public function testSupportsNormalization(): void + { + $error = new Error('test message'); + + $this->assertTrue($this->errorNormalizer->supportsNormalization($error)); + } +} diff --git a/tests/GraphQl/Serializer/Exception/HttpExceptionNormalizerTest.php b/tests/GraphQl/Serializer/Exception/HttpExceptionNormalizerTest.php new file mode 100644 index 00000000000..fadf5b421a7 --- /dev/null +++ b/tests/GraphQl/Serializer/Exception/HttpExceptionNormalizerTest.php @@ -0,0 +1,70 @@ + + * + * 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\Tests\GraphQl\Serializer\Exception; + +use ApiPlatform\Core\GraphQl\Serializer\Exception\HttpExceptionNormalizer; +use GraphQL\Error\Error; +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; +use Symfony\Component\HttpKernel\Exception\HttpException; +use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException; + +/** + * @author Alan Poulain + */ +class HttpExceptionNormalizerTest extends TestCase +{ + private $httpExceptionNormalizer; + + /** + * {@inheritdoc} + */ + protected function setUp(): void + { + parent::setUp(); + + $this->httpExceptionNormalizer = new HttpExceptionNormalizer(); + } + + /** + * @dataProvider exceptionProvider + */ + public function testNormalize(HttpException $exception, string $expectedExceptionMessage, int $expectedStatus, string $expectedCategory): void + { + $error = new Error('test message', null, null, null, null, $exception); + + $normalizedError = $this->httpExceptionNormalizer->normalize($error); + $this->assertSame($expectedExceptionMessage, $normalizedError['message']); + $this->assertSame($expectedStatus, $normalizedError['extensions']['status']); + $this->assertSame($expectedCategory, $normalizedError['extensions']['category']); + } + + public function exceptionProvider(): array + { + $exceptionMessage = 'exception message'; + + return [ + 'client error' => [new BadRequestHttpException($exceptionMessage), $exceptionMessage, 400, 'user'], + 'server error' => [new ServiceUnavailableHttpException(null, $exceptionMessage), $exceptionMessage, 503, Error::CATEGORY_INTERNAL], + ]; + } + + public function testSupportsNormalization(): void + { + $exception = new BadRequestHttpException(); + $error = new Error('test message', null, null, null, null, $exception); + + $this->assertTrue($this->httpExceptionNormalizer->supportsNormalization($error)); + } +} diff --git a/tests/GraphQl/Serializer/Exception/RuntimeExceptionNormalizerTest.php b/tests/GraphQl/Serializer/Exception/RuntimeExceptionNormalizerTest.php new file mode 100644 index 00000000000..2050dc8f772 --- /dev/null +++ b/tests/GraphQl/Serializer/Exception/RuntimeExceptionNormalizerTest.php @@ -0,0 +1,55 @@ + + * + * 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\Tests\GraphQl\Serializer\Exception; + +use ApiPlatform\Core\GraphQl\Serializer\Exception\RuntimeExceptionNormalizer; +use GraphQL\Error\Error; +use PHPUnit\Framework\TestCase; + +/** + * @author Alan Poulain + */ +class RuntimeExceptionNormalizerTest extends TestCase +{ + private $runtimeExceptionNormalizer; + + /** + * {@inheritdoc} + */ + protected function setUp(): void + { + parent::setUp(); + + $this->runtimeExceptionNormalizer = new RuntimeExceptionNormalizer(); + } + + public function testNormalize(): void + { + $exceptionMessage = 'exception message'; + $exception = new \RuntimeException($exceptionMessage); + $error = new Error('test message', null, null, null, null, $exception); + + $normalizedError = $this->runtimeExceptionNormalizer->normalize($error); + $this->assertSame($exceptionMessage, $normalizedError['message']); + $this->assertSame(Error::CATEGORY_INTERNAL, $normalizedError['extensions']['category']); + } + + public function testSupportsNormalization(): void + { + $exception = new \RuntimeException(); + $error = new Error('test message', null, null, null, null, $exception); + + $this->assertTrue($this->runtimeExceptionNormalizer->supportsNormalization($error)); + } +} diff --git a/tests/GraphQl/Serializer/Exception/ValidationExceptionNormalizerTest.php b/tests/GraphQl/Serializer/Exception/ValidationExceptionNormalizerTest.php new file mode 100644 index 00000000000..37d10665279 --- /dev/null +++ b/tests/GraphQl/Serializer/Exception/ValidationExceptionNormalizerTest.php @@ -0,0 +1,73 @@ + + * + * 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\Tests\GraphQl\Serializer\Exception; + +use ApiPlatform\Core\Bridge\Symfony\Validator\Exception\ValidationException; +use ApiPlatform\Core\GraphQl\Serializer\Exception\ValidationExceptionNormalizer; +use GraphQL\Error\Error; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Validator\ConstraintViolation; +use Symfony\Component\Validator\ConstraintViolationList; + +/** + * @author Mahmood Bazdar + */ +class ValidationExceptionNormalizerTest extends TestCase +{ + private $validationExceptionNormalizer; + + /** + * {@inheritdoc} + */ + protected function setUp(): void + { + parent::setUp(); + + $this->validationExceptionNormalizer = new ValidationExceptionNormalizer(); + } + + public function testNormalize(): void + { + $exceptionMessage = 'exception message'; + $exception = new ValidationException(new ConstraintViolationList([ + new ConstraintViolation('message 1', '', [], '', 'field 1', 'invalid'), + new ConstraintViolation('message 2', '', [], '', 'field 2', 'invalid'), + ]), $exceptionMessage); + $error = new Error('test message', null, null, null, null, $exception); + + $normalizedError = $this->validationExceptionNormalizer->normalize($error); + $this->assertSame($exceptionMessage, $normalizedError['message']); + $this->assertSame(400, $normalizedError['extensions']['status']); + $this->assertSame('user', $normalizedError['extensions']['category']); + $this->assertArrayHasKey('violations', $normalizedError['extensions']); + $this->assertSame([ + [ + 'path' => 'field 1', + 'message' => 'message 1', + ], + [ + 'path' => 'field 2', + 'message' => 'message 2', + ], + ], $normalizedError['extensions']['violations']); + } + + public function testSupportsNormalization(): void + { + $exception = new ValidationException(new ConstraintViolationList([])); + $error = new Error('test message', null, null, null, null, $exception); + + $this->assertTrue($this->validationExceptionNormalizer->supportsNormalization($error)); + } +} From bf8afd3be70b2965b27c5395dc3a697bc37a8240 Mon Sep 17 00:00:00 2001 From: Raoul Clais Date: Tue, 19 Nov 2019 14:45:34 +0100 Subject: [PATCH 015/160] Add page-based pagination to GraphQL (#3175) * Add page-based pagination to GraphQL * Use page_parameter_name Co-authored-by: Alan Poulain --- CHANGELOG.md | 1 + features/graphql/collection.feature | 99 +++++++++++++++++++ .../Bundle/Resources/config/graphql.xml | 1 + src/DataProvider/Pagination.php | 12 +++ src/GraphQl/Resolver/Stage/SerializeStage.php | 40 +++++++- src/GraphQl/Type/FieldsBuilder.php | 67 +++++++++---- src/GraphQl/Type/TypeBuilder.php | 76 ++++++++++---- src/GraphQl/Type/TypeBuilderInterface.php | 2 +- .../Fixtures/TestBundle/Document/FooDummy.php | 11 ++- tests/Fixtures/TestBundle/Entity/FooDummy.php | 11 ++- tests/GraphQl/Type/FieldsBuilderTest.php | 25 ++++- tests/GraphQl/Type/TypeBuilderTest.php | 68 ++++++++++++- 12 files changed, 358 insertions(+), 55 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 63a47239a6a..fc25a0d1eeb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ * MongoDB: Possibility to add execute options (aggregate command fields) for a resource, like `allowDiskUse` (#3144) * GraphQL: Allow to format GraphQL errors based on exceptions (#3063) +* GraphQL: Add page-based pagination (#3175) ## 2.5.2 diff --git a/features/graphql/collection.feature b/features/graphql/collection.feature index eb581455912..1080036b166 100644 --- a/features/graphql/collection.feature +++ b/features/graphql/collection.feature @@ -680,3 +680,102 @@ Feature: GraphQL collection support And the JSON node "data.dummyDifferentGraphQlSerializationGroups.edges[0].node.title" should not exist And the JSON node "data.dummyDifferentGraphQlSerializationGroups.edges[1].node.title" should not exist And the JSON node "data.dummyDifferentGraphQlSerializationGroups.edges[2].node.title" should not exist + + @createSchema + Scenario: Retrieve a paginated collection using page-based pagination + Given there are 5 fooDummy objects with fake names + When I send the following GraphQL request: + """ + { + fooDummies(page: 1) { + collection { + id + } + paginationInfo { + itemsPerPage + lastPage + totalCount + } + } + } + """ + Then the response status code should be 200 + And the response should be in JSON + And the JSON node "data.fooDummies.collection" should have 3 elements + And the JSON node "data.fooDummies.collection[0].id" should exist + And the JSON node "data.fooDummies.collection[1].id" should exist + And the JSON node "data.fooDummies.collection[2].id" should exist + And the JSON node "data.fooDummies.paginationInfo.itemsPerPage" should be equal to the number 3 + And the JSON node "data.fooDummies.paginationInfo.lastPage" should be equal to the number 2 + And the JSON node "data.fooDummies.paginationInfo.totalCount" should be equal to the number 5 + When I send the following GraphQL request: + """ + { + fooDummies(page: 2) { + collection { + id + } + } + } + """ + Then the response status code should be 200 + And the response should be in JSON + And the JSON node "data.fooDummies.collection" should have 2 elements + When I send the following GraphQL request: + """ + { + fooDummies(page: 3) { + collection { + id + } + } + } + """ + Then the response status code should be 200 + And the response should be in JSON + And the JSON node "data.fooDummies.collection" should have 0 elements + + @createSchema + Scenario: Retrieve a paginated collection using page-based pagination and client-defined limit + Given there are 5 fooDummy objects with fake names + When I send the following GraphQL request: + """ + { + fooDummies(page: 1, itemsPerPage: 2) { + collection { + id + } + } + } + """ + Then the response status code should be 200 + And the response should be in JSON + And the JSON node "data.fooDummies.collection" should have 2 elements + And the JSON node "data.fooDummies.collection[0].id" should exist + And the JSON node "data.fooDummies.collection[1].id" should exist + When I send the following GraphQL request: + """ + { + fooDummies(page: 2, itemsPerPage: 2) { + collection { + id + } + } + } + """ + Then the response status code should be 200 + And the response should be in JSON + And the JSON node "data.fooDummies.collection" should have 2 elements + When I send the following GraphQL request: + """ + { + fooDummies(page: 3, itemsPerPage: 2) { + collection { + id + } + } + } + """ + Then the response status code should be 200 + And the response should be in JSON + And the JSON node "data.fooDummies.collection" should have 1 element diff --git a/src/Bridge/Symfony/Bundle/Resources/config/graphql.xml b/src/Bridge/Symfony/Bundle/Resources/config/graphql.xml index 117b1ab8f87..ef5cc6d0590 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/graphql.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/graphql.xml @@ -127,6 +127,7 @@ + diff --git a/src/DataProvider/Pagination.php b/src/DataProvider/Pagination.php index 8a9bc41b9cd..b91ea79bd0b 100644 --- a/src/DataProvider/Pagination.php +++ b/src/DataProvider/Pagination.php @@ -196,6 +196,18 @@ public function isPartialEnabled(string $resourceClass = null, string $operation return $this->getEnabled($context, $resourceClass, $operationName, true); } + public function getOptions(): array + { + return $this->options; + } + + public function getGraphQlPaginationType(string $resourceClass, string $operationName): string + { + $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass); + + return (string) $resourceMetadata->getGraphqlAttribute($operationName, 'paginationType', 'cursor', true); + } + /** * Is the classic or partial pagination enabled? */ diff --git a/src/GraphQl/Resolver/Stage/SerializeStage.php b/src/GraphQl/Resolver/Stage/SerializeStage.php index 18e72b51a8e..92cc52a7022 100644 --- a/src/GraphQl/Resolver/Stage/SerializeStage.php +++ b/src/GraphQl/Resolver/Stage/SerializeStage.php @@ -54,7 +54,9 @@ public function __invoke($itemOrCollection, string $resourceClass, string $opera if (!$resourceMetadata->getGraphqlAttribute($operationName, 'serialize', true, true)) { if ($isCollection) { if ($this->pagination->isGraphQlEnabled($resourceClass, $operationName, $context)) { - return $this->getDefaultPaginatedData(); + return 'cursor' === $this->pagination->getGraphQlPaginationType($resourceClass, $operationName) ? + $this->getDefaultCursorBasedPaginatedData() : + $this->getDefaultPageBasedPaginatedData(); } return []; @@ -87,7 +89,9 @@ public function __invoke($itemOrCollection, string $resourceClass, string $opera $data[$index] = $this->normalizer->normalize($object, ItemNormalizer::FORMAT, $normalizationContext); } } else { - $data = $this->serializePaginatedCollection($itemOrCollection, $normalizationContext, $context); + $data = 'cursor' === $this->pagination->getGraphQlPaginationType($resourceClass, $operationName) ? + $this->serializeCursorBasedPaginatedCollection($itemOrCollection, $normalizationContext, $context) : + $this->serializePageBasedPaginatedCollection($itemOrCollection, $normalizationContext); } } @@ -108,7 +112,7 @@ public function __invoke($itemOrCollection, string $resourceClass, string $opera * @throws \LogicException * @throws \UnexpectedValueException */ - private function serializePaginatedCollection(iterable $collection, array $normalizationContext, array $context): array + private function serializeCursorBasedPaginatedCollection(iterable $collection, array $normalizationContext, array $context): array { $args = $context['args']; @@ -138,7 +142,7 @@ private function serializePaginatedCollection(iterable $collection, array $norma } $offset = 0 > $offset ? 0 : $offset; - $data = $this->getDefaultPaginatedData(); + $data = $this->getDefaultCursorBasedPaginatedData(); if (($totalItems = $collection->getTotalItems()) > 0) { $data['totalCount'] = $totalItems; @@ -161,11 +165,37 @@ private function serializePaginatedCollection(iterable $collection, array $norma return $data; } - private function getDefaultPaginatedData(): array + /** + * @throws \LogicException + */ + private function serializePageBasedPaginatedCollection(iterable $collection, array $normalizationContext): array + { + if (!($collection instanceof PaginatorInterface)) { + throw new \LogicException(sprintf('Collection returned by the collection data provider must implement %s.', PaginatorInterface::class)); + } + + $data = $this->getDefaultPageBasedPaginatedData(); + $data['paginationInfo']['totalCount'] = $collection->getTotalItems(); + $data['paginationInfo']['lastPage'] = $collection->getLastPage(); + $data['paginationInfo']['itemsPerPage'] = $collection->getItemsPerPage(); + + foreach ($collection as $object) { + $data['collection'][] = $this->normalizer->normalize($object, ItemNormalizer::FORMAT, $normalizationContext); + } + + return $data; + } + + private function getDefaultCursorBasedPaginatedData(): array { return ['totalCount' => 0., 'edges' => [], 'pageInfo' => ['startCursor' => null, 'endCursor' => null, 'hasNextPage' => false, 'hasPreviousPage' => false]]; } + private function getDefaultPageBasedPaginatedData(): array + { + return ['collection' => [], 'paginationInfo' => ['itemsPerPage' => 0., 'totalCount' => 0., 'lastPage' => 0.]]; + } + private function getDefaultMutationData(array $context): array { return ['clientMutationId' => $context['args']['input']['clientMutationId'] ?? null]; diff --git a/src/GraphQl/Type/FieldsBuilder.php b/src/GraphQl/Type/FieldsBuilder.php index 64d0d47a75c..a18e24e654c 100644 --- a/src/GraphQl/Type/FieldsBuilder.php +++ b/src/GraphQl/Type/FieldsBuilder.php @@ -254,24 +254,7 @@ private function getResourceFieldConfiguration(?string $property, ?string $field $args = []; if (!$input && null === $mutationName && !$isStandardGraphqlType && $this->typeBuilder->isCollection($type)) { if ($this->pagination->isGraphQlEnabled($resourceClass, $queryName)) { - $args = [ - 'first' => [ - 'type' => GraphQLType::int(), - 'description' => 'Returns the first n elements from the list.', - ], - 'last' => [ - 'type' => GraphQLType::int(), - 'description' => 'Returns the last n elements from the list.', - ], - 'before' => [ - 'type' => GraphQLType::string(), - 'description' => 'Returns the elements in the list that come before the specified cursor.', - ], - 'after' => [ - 'type' => GraphQLType::string(), - 'description' => 'Returns the elements in the list that come after the specified cursor.', - ], - ]; + $args = $this->getGraphQlPaginationArgs($resourceClass, $queryName); } $args = $this->getFilterArgs($args, $resourceClass, $resourceMetadata, $rootResource, $property, $queryName, $mutationName, $depth); @@ -299,6 +282,50 @@ private function getResourceFieldConfiguration(?string $property, ?string $field return null; } + private function getGraphQlPaginationArgs(string $resourceClass, string $queryName): array + { + $paginationType = $this->pagination->getGraphQlPaginationType($resourceClass, $queryName); + + if ('cursor' === $paginationType) { + return [ + 'first' => [ + 'type' => GraphQLType::int(), + 'description' => 'Returns the first n elements from the list.', + ], + 'last' => [ + 'type' => GraphQLType::int(), + 'description' => 'Returns the last n elements from the list.', + ], + 'before' => [ + 'type' => GraphQLType::string(), + 'description' => 'Returns the elements in the list that come before the specified cursor.', + ], + 'after' => [ + 'type' => GraphQLType::string(), + 'description' => 'Returns the elements in the list that come after the specified cursor.', + ], + ]; + } + + $paginationOptions = $this->pagination->getOptions(); + + $args = [ + $paginationOptions['page_parameter_name'] => [ + 'type' => GraphQLType::int(), + 'description' => 'Returns the current page.', + ], + ]; + + if ($paginationOptions['client_items_per_page']) { + $args[$paginationOptions['items_per_page_parameter_name']] = [ + 'type' => GraphQLType::int(), + 'description' => 'Returns the number of items per page.', + ]; + } + + return $args; + } + private function getFilterArgs(array $args, ?string $resourceClass, ?ResourceMetadata $resourceMetadata, string $rootResource, ?string $property, ?string $queryName, ?string $mutationName, int $depth): array { if (null === $resourceMetadata || null === $resourceClass) { @@ -418,7 +445,9 @@ private function convertType(Type $type, bool $input, ?string $queryName, ?strin } if ($this->typeBuilder->isCollection($type)) { - return $this->pagination->isGraphQlEnabled($resourceClass, $queryName ?? $mutationName) && !$input ? $this->typeBuilder->getResourcePaginatedCollectionType($graphqlType) : GraphQLType::listOf($graphqlType); + $operationName = $queryName ?? $mutationName; + + return $this->pagination->isGraphQlEnabled($resourceClass, $operationName) && !$input ? $this->typeBuilder->getResourcePaginatedCollectionType($graphqlType, $resourceClass, $operationName) : GraphQLType::listOf($graphqlType); } return !$graphqlType instanceof NullableType || $type->isNullable() || (null !== $mutationName && 'update' === $mutationName) diff --git a/src/GraphQl/Type/TypeBuilder.php b/src/GraphQl/Type/TypeBuilder.php index 66b6b23fcc3..c4c3d87d65c 100644 --- a/src/GraphQl/Type/TypeBuilder.php +++ b/src/GraphQl/Type/TypeBuilder.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Core\GraphQl\Type; +use ApiPlatform\Core\DataProvider\Pagination; use ApiPlatform\Core\GraphQl\Serializer\ItemNormalizer; use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; use GraphQL\Type\Definition\InputObjectType; @@ -35,12 +36,14 @@ final class TypeBuilder implements TypeBuilderInterface private $typesContainer; private $defaultFieldResolver; private $fieldsBuilderLocator; + private $pagination; - public function __construct(TypesContainerInterface $typesContainer, callable $defaultFieldResolver, ContainerInterface $fieldsBuilderLocator) + public function __construct(TypesContainerInterface $typesContainer, callable $defaultFieldResolver, ContainerInterface $fieldsBuilderLocator, Pagination $pagination) { $this->typesContainer = $typesContainer; $this->defaultFieldResolver = $defaultFieldResolver; $this->fieldsBuilderLocator = $fieldsBuilderLocator; + $this->pagination = $pagination; } /** @@ -171,7 +174,7 @@ public function getNodeInterface(): InterfaceType /** * {@inheritdoc} */ - public function getResourcePaginatedCollectionType(GraphQLType $resourceType): GraphQLType + public function getResourcePaginatedCollectionType(GraphQLType $resourceType, string $resourceClass, string $operationName): GraphQLType { $shortName = $resourceType->name; @@ -179,6 +182,36 @@ public function getResourcePaginatedCollectionType(GraphQLType $resourceType): G return $this->typesContainer->get("{$shortName}Connection"); } + $paginationType = $this->pagination->getGraphQlPaginationType($resourceClass, $operationName); + + $fields = 'cursor' === $paginationType ? + $this->getCursorBasedPaginationFields($resourceType) : + $this->getPageBasedPaginationFields($resourceType); + + $configuration = [ + 'name' => "{$shortName}Connection", + 'description' => "Connection for $shortName.", + 'fields' => $fields, + ]; + + $resourcePaginatedCollectionType = new ObjectType($configuration); + $this->typesContainer->set("{$shortName}Connection", $resourcePaginatedCollectionType); + + return $resourcePaginatedCollectionType; + } + + /** + * {@inheritdoc} + */ + public function isCollection(Type $type): bool + { + return $type->isCollection() && Type::BUILTIN_TYPE_OBJECT === $type->getBuiltinType(); + } + + private function getCursorBasedPaginationFields(GraphQLType $resourceType): array + { + $shortName = $resourceType->name; + $edgeObjectTypeConfiguration = [ 'name' => "{$shortName}Edge", 'description' => "Edge of $shortName.", @@ -203,27 +236,32 @@ public function getResourcePaginatedCollectionType(GraphQLType $resourceType): G $pageInfoObjectType = new ObjectType($pageInfoObjectTypeConfiguration); $this->typesContainer->set("{$shortName}PageInfo", $pageInfoObjectType); - $configuration = [ - 'name' => "{$shortName}Connection", - 'description' => "Connection for $shortName.", + return [ + 'edges' => GraphQLType::listOf($edgeObjectType), + 'pageInfo' => GraphQLType::nonNull($pageInfoObjectType), + 'totalCount' => GraphQLType::nonNull(GraphQLType::int()), + ]; + } + + private function getPageBasedPaginationFields(GraphQLType $resourceType): array + { + $shortName = $resourceType->name; + + $paginationInfoObjectTypeConfiguration = [ + 'name' => "{$shortName}PaginationInfo", + 'description' => 'Information about the pagination.', 'fields' => [ - 'edges' => GraphQLType::listOf($edgeObjectType), - 'pageInfo' => GraphQLType::nonNull($pageInfoObjectType), + 'itemsPerPage' => GraphQLType::nonNull(GraphQLType::int()), + 'lastPage' => GraphQLType::nonNull(GraphQLType::int()), 'totalCount' => GraphQLType::nonNull(GraphQLType::int()), ], ]; + $paginationInfoObjectType = new ObjectType($paginationInfoObjectTypeConfiguration); + $this->typesContainer->set("{$shortName}PaginationInfo", $paginationInfoObjectType); - $resourcePaginatedCollectionType = new ObjectType($configuration); - $this->typesContainer->set("{$shortName}Connection", $resourcePaginatedCollectionType); - - return $resourcePaginatedCollectionType; - } - - /** - * {@inheritdoc} - */ - public function isCollection(Type $type): bool - { - return $type->isCollection() && Type::BUILTIN_TYPE_OBJECT === $type->getBuiltinType(); + return [ + 'collection' => GraphQLType::listOf($resourceType), + 'paginationInfo' => GraphQLType::nonNull($paginationInfoObjectType), + ]; } } diff --git a/src/GraphQl/Type/TypeBuilderInterface.php b/src/GraphQl/Type/TypeBuilderInterface.php index 138cf8bd3e0..b59c2093e5a 100644 --- a/src/GraphQl/Type/TypeBuilderInterface.php +++ b/src/GraphQl/Type/TypeBuilderInterface.php @@ -44,7 +44,7 @@ public function getNodeInterface(): InterfaceType; /** * Gets the type of a paginated collection of the given resource type. */ - public function getResourcePaginatedCollectionType(GraphQLType $resourceType): GraphQLType; + public function getResourcePaginatedCollectionType(GraphQLType $resourceType, string $resourceClass, string $operationName): GraphQLType; /** * Returns true if a type is a collection. diff --git a/tests/Fixtures/TestBundle/Document/FooDummy.php b/tests/Fixtures/TestBundle/Document/FooDummy.php index 2cb5ef90f57..90c9de2aec8 100644 --- a/tests/Fixtures/TestBundle/Document/FooDummy.php +++ b/tests/Fixtures/TestBundle/Document/FooDummy.php @@ -21,9 +21,14 @@ * * @author Vincent Chalamon * - * @ApiResource(attributes={ - * "order"={"dummy.name"} - * }) + * @ApiResource( + * attributes={ + * "order"={"dummy.name"} + * }, + * graphql={ + * "collection_query"={"paginationType"="page"} + * } + * ) * @ODM\Document */ class FooDummy diff --git a/tests/Fixtures/TestBundle/Entity/FooDummy.php b/tests/Fixtures/TestBundle/Entity/FooDummy.php index a06658a5707..1ec0a15a8cb 100644 --- a/tests/Fixtures/TestBundle/Entity/FooDummy.php +++ b/tests/Fixtures/TestBundle/Entity/FooDummy.php @@ -21,9 +21,14 @@ * * @author Vincent Chalamon * - * @ApiResource(attributes={ - * "order"={"dummy.name"} - * }) + * @ApiResource( + * attributes={ + * "order"={"dummy.name"} + * }, + * graphql={ + * "collection_query"={"paginationType"="page"} + * } + * ) * @ORM\Entity */ class FooDummy diff --git a/tests/GraphQl/Type/FieldsBuilderTest.php b/tests/GraphQl/Type/FieldsBuilderTest.php index 014113d45ed..5ae8f81fd29 100644 --- a/tests/GraphQl/Type/FieldsBuilderTest.php +++ b/tests/GraphQl/Type/FieldsBuilderTest.php @@ -206,7 +206,7 @@ public function testGetCollectionQueryFields(string $resourceClass, ResourceMeta $this->typeConverterProphecy->convertType(Argument::type(Type::class), false, $queryName, null, $resourceClass, $resourceClass, null, 0)->willReturn($graphqlType); $this->typeConverterProphecy->resolveType(Argument::type('string'))->willReturn(GraphQLType::string()); $this->typeBuilderProphecy->isCollection(Argument::type(Type::class))->willReturn(true); - $this->typeBuilderProphecy->getResourcePaginatedCollectionType($graphqlType)->willReturn($graphqlType); + $this->typeBuilderProphecy->getResourcePaginatedCollectionType($graphqlType, $resourceClass, $queryName)->willReturn($graphqlType); $this->resourceMetadataFactoryProphecy->create($resourceClass)->willReturn($resourceMetadata); $this->collectionResolverFactoryProphecy->__invoke($resourceClass, $resourceClass, $queryName)->willReturn($resolver); $this->filterLocatorProphecy->has('my_filter')->willReturn(true); @@ -325,6 +325,27 @@ public function collectionQueryFieldsProvider(): array ], ], ], + 'collection with page-based pagination enabled' => ['resourceClass', (new ResourceMetadata('ShortName', null, null, null, null, ['paginationType' => 'page']))->withGraphql(['action' => ['filters' => ['my_filter']]]), 'action', [], $graphqlType = GraphQLType::listOf(new ObjectType(['name' => 'collection'])), $resolver = function () { + }, + [ + 'actionShortNames' => [ + 'type' => $graphqlType, + 'description' => null, + 'args' => [ + 'page' => [ + 'type' => GraphQLType::int(), + 'description' => 'Returns the current page.', + ], + 'boolField' => $graphqlType, + 'boolField_list' => GraphQLType::listOf($graphqlType), + 'parent__child' => new InputObjectType(['name' => 'ShortNameFilter_parent__child', 'fields' => ['related__nested' => $graphqlType]]), + 'dateField' => new InputObjectType(['name' => 'ShortNameFilter_dateField', 'fields' => ['before' => $graphqlType]]), + ], + 'resolve' => $resolver, + 'deprecationReason' => '', + ], + ], + ], ]; } @@ -336,7 +357,7 @@ public function testGetMutationFields(string $resourceClass, ResourceMetadata $r $this->typeConverterProphecy->convertType(Argument::type(Type::class), false, null, $mutationName, $resourceClass, $resourceClass, null, 0)->willReturn($graphqlType); $this->typeConverterProphecy->convertType(Argument::type(Type::class), true, null, $mutationName, $resourceClass, $resourceClass, null, 0)->willReturn($graphqlType); $this->typeBuilderProphecy->isCollection(Argument::type(Type::class))->willReturn($isTypeCollection); - $this->typeBuilderProphecy->getResourcePaginatedCollectionType($graphqlType)->willReturn($graphqlType); + $this->typeBuilderProphecy->getResourcePaginatedCollectionType($graphqlType, $resourceClass, $mutationName)->willReturn($graphqlType); $this->resourceMetadataFactoryProphecy->create($resourceClass)->willReturn(new ResourceMetadata()); $this->collectionResolverFactoryProphecy->__invoke($resourceClass, $resourceClass, null)->willReturn($collectionResolver); $this->itemMutationResolverFactoryProphecy->__invoke($resourceClass, null, $mutationName)->willReturn($mutationResolver); diff --git a/tests/GraphQl/Type/TypeBuilderTest.php b/tests/GraphQl/Type/TypeBuilderTest.php index 557686991b8..4875ebf0905 100644 --- a/tests/GraphQl/Type/TypeBuilderTest.php +++ b/tests/GraphQl/Type/TypeBuilderTest.php @@ -13,10 +13,12 @@ namespace ApiPlatform\Core\Tests\GraphQl\Type; +use ApiPlatform\Core\DataProvider\Pagination; use ApiPlatform\Core\GraphQl\Serializer\ItemNormalizer; use ApiPlatform\Core\GraphQl\Type\FieldsBuilderInterface; use ApiPlatform\Core\GraphQl\Type\TypeBuilder; use ApiPlatform\Core\GraphQl\Type\TypesContainerInterface; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy; use GraphQL\Type\Definition\InputObjectType; @@ -46,6 +48,9 @@ class TypeBuilderTest extends TestCase /** @var ObjectProphecy */ private $fieldsBuilderLocatorProphecy; + /** @var ObjectProphecy */ + private $resourceMetadataFactoryProphecy; + /** @var TypeBuilder */ private $typeBuilder; @@ -58,7 +63,13 @@ protected function setUp(): void $this->defaultFieldResolver = function () { }; $this->fieldsBuilderLocatorProphecy = $this->prophesize(ContainerInterface::class); - $this->typeBuilder = new TypeBuilder($this->typesContainerProphecy->reveal(), $this->defaultFieldResolver, $this->fieldsBuilderLocatorProphecy->reveal()); + $this->resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $this->typeBuilder = new TypeBuilder( + $this->typesContainerProphecy->reveal(), + $this->defaultFieldResolver, + $this->fieldsBuilderLocatorProphecy->reveal(), + new Pagination($this->resourceMetadataFactoryProphecy->reveal()) + ); } public function testGetResourceObjectType(): void @@ -316,15 +327,24 @@ public function testGetNodeInterface(): void $this->assertSame(GraphQLType::string(), $resolvedType); } - public function testGetResourcePaginatedCollectionType(): void + public function testCursorBasedGetResourcePaginatedCollectionType(): void { $this->typesContainerProphecy->has('StringConnection')->shouldBeCalled()->willReturn(false); $this->typesContainerProphecy->set('StringConnection', Argument::type(ObjectType::class))->shouldBeCalled(); $this->typesContainerProphecy->set('StringEdge', Argument::type(ObjectType::class))->shouldBeCalled(); $this->typesContainerProphecy->set('StringPageInfo', Argument::type(ObjectType::class))->shouldBeCalled(); + $this->resourceMetadataFactoryProphecy->create('StringResourceClass')->shouldBeCalled()->willReturn(new ResourceMetadata( + null, + null, + null, + null, + null, + ['paginationType' => 'cursor'] + )); + /** @var ObjectType $resourcePaginatedCollectionType */ - $resourcePaginatedCollectionType = $this->typeBuilder->getResourcePaginatedCollectionType(GraphQLType::string()); + $resourcePaginatedCollectionType = $this->typeBuilder->getResourcePaginatedCollectionType(GraphQLType::string(), 'StringResourceClass', 'operationName'); $this->assertSame('StringConnection', $resourcePaginatedCollectionType->name); $this->assertSame('Connection for String.', $resourcePaginatedCollectionType->description); @@ -370,6 +390,48 @@ public function testGetResourcePaginatedCollectionType(): void $this->assertSame(GraphQLType::int(), $totalCountType->getWrappedType()); } + public function testPageBasedGetResourcePaginatedCollectionType(): void + { + $this->typesContainerProphecy->has('StringConnection')->shouldBeCalled()->willReturn(false); + $this->typesContainerProphecy->set('StringConnection', Argument::type(ObjectType::class))->shouldBeCalled(); + $this->typesContainerProphecy->set('StringPaginationInfo', Argument::type(ObjectType::class))->shouldBeCalled(); + + $this->resourceMetadataFactoryProphecy->create('StringResourceClass')->shouldBeCalled()->willReturn(new ResourceMetadata( + null, + null, + null, + null, + null, + ['paginationType' => 'page'] + )); + + /** @var ObjectType $resourcePaginatedCollectionType */ + $resourcePaginatedCollectionType = $this->typeBuilder->getResourcePaginatedCollectionType(GraphQLType::string(), 'StringResourceClass', 'operationName'); + $this->assertSame('StringConnection', $resourcePaginatedCollectionType->name); + $this->assertSame('Connection for String.', $resourcePaginatedCollectionType->description); + + $resourcePaginatedCollectionTypeFields = $resourcePaginatedCollectionType->getFields(); + $this->assertArrayHasKey('collection', $resourcePaginatedCollectionTypeFields); + $this->assertArrayHasKey('paginationInfo', $resourcePaginatedCollectionTypeFields); + + /** @var NonNull $paginationInfoType */ + $paginationInfoType = $resourcePaginatedCollectionTypeFields['paginationInfo']->getType(); + /** @var ObjectType $wrappedType */ + $wrappedType = $paginationInfoType->getWrappedType(); + $this->assertSame('StringPaginationInfo', $wrappedType->name); + $this->assertSame('Information about the pagination.', $wrappedType->description); + $paginationInfoObjectTypeFields = $wrappedType->getFields(); + $this->assertArrayHasKey('itemsPerPage', $paginationInfoObjectTypeFields); + $this->assertArrayHasKey('lastPage', $paginationInfoObjectTypeFields); + $this->assertArrayHasKey('totalCount', $paginationInfoObjectTypeFields); + $this->assertInstanceOf(NonNull::class, $paginationInfoObjectTypeFields['itemsPerPage']->getType()); + $this->assertSame(GraphQLType::int(), $paginationInfoObjectTypeFields['itemsPerPage']->getType()->getWrappedType()); + $this->assertInstanceOf(NonNull::class, $paginationInfoObjectTypeFields['lastPage']->getType()); + $this->assertSame(GraphQLType::int(), $paginationInfoObjectTypeFields['lastPage']->getType()->getWrappedType()); + $this->assertInstanceOf(NonNull::class, $paginationInfoObjectTypeFields['totalCount']->getType()); + $this->assertSame(GraphQLType::int(), $paginationInfoObjectTypeFields['totalCount']->getType()->getWrappedType()); + } + /** * @dataProvider typesProvider */ From 2430ab5793fdbfd78561fc1b0374af5902daedeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Dunglas?= Date: Tue, 19 Nov 2019 23:49:44 +0100 Subject: [PATCH 016/160] Improve Vulcain support (#3275) * Improve Vulcain support * Add missing @id property --- src/Hydra/Serializer/CollectionNormalizer.php | 10 +++- .../Serializer/CollectionNormalizerTest.php | 46 +++++++++++++++++++ 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/src/Hydra/Serializer/CollectionNormalizer.php b/src/Hydra/Serializer/CollectionNormalizer.php index c7ac9d13f6c..bece5e16396 100644 --- a/src/Hydra/Serializer/CollectionNormalizer.php +++ b/src/Hydra/Serializer/CollectionNormalizer.php @@ -39,16 +39,21 @@ final class CollectionNormalizer implements NormalizerInterface, NormalizerAware use NormalizerAwareTrait; public const FORMAT = 'jsonld'; + public const IRI_ONLY = 'iri_only'; private $contextBuilder; private $resourceClassResolver; private $iriConverter; + private $defaultContext = [ + self::IRI_ONLY => false, + ]; - public function __construct(ContextBuilderInterface $contextBuilder, ResourceClassResolverInterface $resourceClassResolver, IriConverterInterface $iriConverter) + public function __construct(ContextBuilderInterface $contextBuilder, ResourceClassResolverInterface $resourceClassResolver, IriConverterInterface $iriConverter, array $defaultContext = []) { $this->contextBuilder = $contextBuilder; $this->resourceClassResolver = $resourceClassResolver; $this->iriConverter = $iriConverter; + $this->defaultContext = array_merge($this->defaultContext, $defaultContext); } /** @@ -83,8 +88,9 @@ public function normalize($object, $format = null, array $context = []) $data['@type'] = 'hydra:Collection'; $data['hydra:member'] = []; + $iriOnly = $context[self::IRI_ONLY] ?? $this->defaultContext[self::IRI_ONLY]; foreach ($object as $obj) { - $data['hydra:member'][] = $this->normalizer->normalize($obj, $format, $context); + $data['hydra:member'][] = $iriOnly ? ['@id' => $this->iriConverter->getIriFromItem($obj)] : $this->normalizer->normalize($obj, $format, $context); } $paginated = null; diff --git a/tests/Hydra/Serializer/CollectionNormalizerTest.php b/tests/Hydra/Serializer/CollectionNormalizerTest.php index 2ef97fd951b..c98cc1a1cd1 100644 --- a/tests/Hydra/Serializer/CollectionNormalizerTest.php +++ b/tests/Hydra/Serializer/CollectionNormalizerTest.php @@ -328,4 +328,50 @@ private function normalizePaginator($partial = false) 'resource_class' => 'Foo', ]); } + + public function testNormalizeIriOnlyResourceCollection(): void + { + $fooOne = new Foo(); + $fooOne->id = 1; + $fooOne->bar = 'baz'; + + $fooThree = new Foo(); + $fooThree->id = 3; + $fooThree->bar = 'bzz'; + + $data = [$fooOne, $fooThree]; + + $contextBuilderProphecy = $this->prophesize(ContextBuilderInterface::class); + $contextBuilderProphecy->getResourceContextUri(Foo::class)->willReturn('/contexts/Foo'); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass($data, Foo::class)->willReturn(Foo::class); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getIriFromResourceClass(Foo::class)->willReturn('/foos'); + $iriConverterProphecy->getIriFromItem($fooOne)->willReturn('/foos/1'); + $iriConverterProphecy->getIriFromItem($fooThree)->willReturn('/foos/3'); + + $delegateNormalizerProphecy = $this->prophesize(NormalizerInterface::class); + + $normalizer = new CollectionNormalizer($contextBuilderProphecy->reveal(), $resourceClassResolverProphecy->reveal(), $iriConverterProphecy->reveal(), [CollectionNormalizer::IRI_ONLY => true]); + $normalizer->setNormalizer($delegateNormalizerProphecy->reveal()); + + $actual = $normalizer->normalize($data, CollectionNormalizer::FORMAT, [ + 'collection_operation_name' => 'get', + 'operation_type' => OperationType::COLLECTION, + 'resource_class' => Foo::class, + ]); + + $this->assertSame([ + '@context' => '/contexts/Foo', + '@id' => '/foos', + '@type' => 'hydra:Collection', + 'hydra:member' => [ + ['@id' => '/foos/1'], + ['@id' => '/foos/3'], + ], + 'hydra:totalItems' => 2, + ], $actual); + } } From be5752f43ed8c3f0f8b771e29d15dcf2b55599fc Mon Sep 17 00:00:00 2001 From: Teoh Han Hui Date: Sat, 9 Nov 2019 15:19:47 +0100 Subject: [PATCH 017/160] Remove nonsensical "child inherited" metadata --- .../Resources/config/metadata/metadata.xml | 10 --- ...nnotationPropertyNameCollectionFactory.php | 4 +- ...ExtractorPropertyNameCollectionFactory.php | 2 +- .../InheritedPropertyMetadataFactory.php | 8 ++- ...InheritedPropertyNameCollectionFactory.php | 8 ++- .../SerializerPropertyMetadataFactory.php | 3 +- src/Metadata/Property/PropertyMetadata.php | 20 ++++-- src/Serializer/AbstractItemNormalizer.php | 44 +++++++----- .../ApiPlatformExtensionTest.php | 2 - .../config/serialization/abstract_dummy.yml | 16 ++--- .../JsonApi/Serializer/ItemNormalizerTest.php | 67 ++++++++++++++++++- .../InheritedPropertyMetadataFactoryTest.php | 2 +- ...ritedPropertyNameCollectionFactoryTest.php | 2 +- .../SerializerPropertyMetadataFactoryTest.php | 8 +-- .../Serializer/AbstractItemNormalizerTest.php | 5 +- 15 files changed, 138 insertions(+), 63 deletions(-) diff --git a/src/Bridge/Symfony/Bundle/Resources/config/metadata/metadata.xml b/src/Bridge/Symfony/Bundle/Resources/config/metadata/metadata.xml index 1a50a9b0e14..9f07bea8fc3 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/metadata/metadata.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/metadata/metadata.xml @@ -49,11 +49,6 @@ - - - - - @@ -67,11 +62,6 @@ - - - - - diff --git a/src/Metadata/Property/Factory/AnnotationPropertyNameCollectionFactory.php b/src/Metadata/Property/Factory/AnnotationPropertyNameCollectionFactory.php index eae2bcd3293..a847c13e8ef 100644 --- a/src/Metadata/Property/Factory/AnnotationPropertyNameCollectionFactory.php +++ b/src/Metadata/Property/Factory/AnnotationPropertyNameCollectionFactory.php @@ -48,7 +48,7 @@ public function create(string $resourceClass, array $options = []): PropertyName try { $propertyNameCollection = $this->decorated->create($resourceClass, $options); } catch (ResourceClassNotFoundException $resourceClassNotFoundException) { - // Ignore not found exceptions from parent + // Ignore not found exceptions from decorated factory } } @@ -87,7 +87,7 @@ public function create(string $resourceClass, array $options = []): PropertyName } } - // Inherited from parent + // add property names from decorated factory if (null !== $propertyNameCollection) { foreach ($propertyNameCollection as $propertyName) { $propertyNames[$propertyName] = $propertyName; diff --git a/src/Metadata/Property/Factory/ExtractorPropertyNameCollectionFactory.php b/src/Metadata/Property/Factory/ExtractorPropertyNameCollectionFactory.php index 57421e8e88c..8ea1fc92489 100644 --- a/src/Metadata/Property/Factory/ExtractorPropertyNameCollectionFactory.php +++ b/src/Metadata/Property/Factory/ExtractorPropertyNameCollectionFactory.php @@ -49,7 +49,7 @@ public function create(string $resourceClass, array $options = []): PropertyName try { $propertyNameCollection = $this->decorated->create($resourceClass, $options); } catch (ResourceClassNotFoundException $resourceClassNotFoundException) { - // Ignore not found exceptions from parent + // Ignore not found exceptions from decorated factory } foreach ($propertyNameCollection as $propertyName) { diff --git a/src/Metadata/Property/Factory/InheritedPropertyMetadataFactory.php b/src/Metadata/Property/Factory/InheritedPropertyMetadataFactory.php index 7d494d6347d..4ff268a6372 100644 --- a/src/Metadata/Property/Factory/InheritedPropertyMetadataFactory.php +++ b/src/Metadata/Property/Factory/InheritedPropertyMetadataFactory.php @@ -17,9 +17,7 @@ use ApiPlatform\Core\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; /** - * Get property metadata from eventual child inherited properties. - * - * @author Antoine Bluchet + * @deprecated since 2.6, to be removed in 3.0 */ final class InheritedPropertyMetadataFactory implements PropertyMetadataFactoryInterface { @@ -28,6 +26,8 @@ final class InheritedPropertyMetadataFactory implements PropertyMetadataFactoryI public function __construct(ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, PropertyMetadataFactoryInterface $decorated = null) { + @trigger_error(sprintf('"%s" is deprecated since 2.6 and will be removed in 3.0.', __CLASS__), E_USER_DEPRECATED); + $this->resourceNameCollectionFactory = $resourceNameCollectionFactory; $this->decorated = $decorated; } @@ -37,6 +37,8 @@ public function __construct(ResourceNameCollectionFactoryInterface $resourceName */ public function create(string $resourceClass, string $property, array $options = []): PropertyMetadata { + @trigger_error(sprintf('"%s" is deprecated since 2.6 and will be removed in 3.0.', __CLASS__), E_USER_DEPRECATED); + $propertyMetadata = $this->decorated ? $this->decorated->create($resourceClass, $property, $options) : new PropertyMetadata(); foreach ($this->resourceNameCollectionFactory->create() as $knownResourceClass) { diff --git a/src/Metadata/Property/Factory/InheritedPropertyNameCollectionFactory.php b/src/Metadata/Property/Factory/InheritedPropertyNameCollectionFactory.php index 8795cc4126c..63f64bf49da 100644 --- a/src/Metadata/Property/Factory/InheritedPropertyNameCollectionFactory.php +++ b/src/Metadata/Property/Factory/InheritedPropertyNameCollectionFactory.php @@ -17,9 +17,7 @@ use ApiPlatform\Core\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; /** - * Creates a property name collection from eventual child inherited properties. - * - * @author Antoine Bluchet + * @deprecated since 2.6, to be removed in 3.0 */ final class InheritedPropertyNameCollectionFactory implements PropertyNameCollectionFactoryInterface { @@ -28,6 +26,8 @@ final class InheritedPropertyNameCollectionFactory implements PropertyNameCollec public function __construct(ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, PropertyNameCollectionFactoryInterface $decorated = null) { + @trigger_error(sprintf('"%s" is deprecated since 2.6 and will be removed in 3.0.', __CLASS__), E_USER_DEPRECATED); + $this->resourceNameCollectionFactory = $resourceNameCollectionFactory; $this->decorated = $decorated; } @@ -37,6 +37,8 @@ public function __construct(ResourceNameCollectionFactoryInterface $resourceName */ public function create(string $resourceClass, array $options = []): PropertyNameCollection { + @trigger_error(sprintf('"%s" is deprecated since 2.6 and will be removed in 3.0.', __CLASS__), E_USER_DEPRECATED); + $propertyNames = []; // Inherited from parent diff --git a/src/Metadata/Property/Factory/SerializerPropertyMetadataFactory.php b/src/Metadata/Property/Factory/SerializerPropertyMetadataFactory.php index dd165208b26..226e84c95a7 100644 --- a/src/Metadata/Property/Factory/SerializerPropertyMetadataFactory.php +++ b/src/Metadata/Property/Factory/SerializerPropertyMetadataFactory.php @@ -48,8 +48,7 @@ public function create(string $resourceClass, string $property, array $options = { $propertyMetadata = $this->decorated->create($resourceClass, $property, $options); - // in case of a property inherited (in a child class), we need it's properties - // to be mapped against serialization groups instead of the parent ones. + // BC to be removed in 3.0 if (null !== ($childResourceClass = $propertyMetadata->getChildInherited())) { $resourceClass = $childResourceClass; } diff --git a/src/Metadata/Property/PropertyMetadata.php b/src/Metadata/Property/PropertyMetadata.php index 1cdb697fe75..6919a30b611 100644 --- a/src/Metadata/Property/PropertyMetadata.php +++ b/src/Metadata/Property/PropertyMetadata.php @@ -31,6 +31,9 @@ final class PropertyMetadata private $required; private $iri; private $identifier; + /** + * @deprecated since 2.6, to be removed in 3.0 + */ private $childInherited; private $attributes; private $subresource; @@ -47,6 +50,9 @@ public function __construct(Type $type = null, string $description = null, bool $this->required = $required; $this->identifier = $identifier; $this->iri = $iri; + if (null !== $childInherited) { + @trigger_error(sprintf('Providing a non-null value for the 10th argument ($childInherited) of the "%s" constructor is deprecated since 2.6 and will not be supported in 3.0.', __CLASS__), E_USER_DEPRECATED); + } $this->childInherited = $childInherited; $this->attributes = $attributes; $this->subresource = $subresource; @@ -258,7 +264,7 @@ public function withAttributes(array $attributes): self } /** - * Gets child inherited. + * @deprecated since 2.6, to be removed in 3.0 */ public function getChildInherited(): ?string { @@ -266,7 +272,7 @@ public function getChildInherited(): ?string } /** - * Is the property inherited from a child class? + * @deprecated since 2.6, to be removed in 3.0 */ public function hasChildInherited(): bool { @@ -274,22 +280,22 @@ public function hasChildInherited(): bool } /** - * Is the property inherited from a child class? - * - * @deprecated since version 2.4, to be removed in 3.0. + * @deprecated since 2.4, to be removed in 3.0 */ public function isChildInherited(): ?string { - @trigger_error(sprintf('The use of "%1$s::isChildInherited()" is deprecated since 2.4 and will be removed in 3.0. Use "%1$s::getChildInherited()" or "%1$s::hasChildInherited()" directly instead.', __CLASS__), E_USER_DEPRECATED); + @trigger_error(sprintf('"%s::%s" is deprecated since 2.4 and will be removed in 3.0.', __CLASS__, __METHOD__), E_USER_DEPRECATED); return $this->getChildInherited(); } /** - * Returns a new instance with the given child inherited class. + * @deprecated since 2.6, to be removed in 3.0 */ public function withChildInherited(string $childInherited): self { + @trigger_error(sprintf('"%s::%s" is deprecated since 2.6 and will be removed in 3.0.', __CLASS__, __METHOD__), E_USER_DEPRECATED); + $metadata = clone $this; $metadata->childInherited = $childInherited; diff --git a/src/Serializer/AbstractItemNormalizer.php b/src/Serializer/AbstractItemNormalizer.php index 8c887dbcce3..9046325f1d1 100644 --- a/src/Serializer/AbstractItemNormalizer.php +++ b/src/Serializer/AbstractItemNormalizer.php @@ -169,7 +169,11 @@ public function supportsDenormalization($data, $type, $format = null) */ public function denormalize($data, $class, $format = null, array $context = []) { - $resourceClass = $this->resourceClassResolver->getResourceClass(null, $class); + if (null === $objectToPopulate = $this->extractObjectToPopulate($class, $context, static::OBJECT_TO_POPULATE)) { + $normalizedData = $this->prepareForDenormalization($data); + $class = $this->getClassDiscriminatorResolvedClass($normalizedData, $class); + } + $resourceClass = $this->resourceClassResolver->getResourceClass($objectToPopulate, $class); $context['api_denormalize'] = true; $context['resource_class'] = $resourceClass; @@ -223,8 +227,7 @@ public function denormalize($data, $class, $format = null, array $context = []) } /** - * Method copy-pasted from symfony/serializer. - * Remove it after symfony/serializer version update @link https://github.com/symfony/symfony/pull/28263. + * Originally from {@see https://github.com/symfony/symfony/pull/28263}. Refactor after it is merged. * * {@inheritdoc} * @@ -238,19 +241,8 @@ protected function instantiateObject(array &$data, $class, array &$context, \Ref return $object; } - if ($this->classDiscriminatorResolver && $mapping = $this->classDiscriminatorResolver->getMappingForClass($class)) { - if (!isset($data[$mapping->getTypeProperty()])) { - throw new RuntimeException(sprintf('Type property "%s" not found for the abstract object "%s"', $mapping->getTypeProperty(), $class)); - } - - $type = $data[$mapping->getTypeProperty()]; - if (null === ($mappedClass = $mapping->getClassForType($type))) { - throw new RuntimeException(sprintf('The type "%s" has no mapped class for the abstract object "%s"', $type, $class)); - } - - $class = $mappedClass; - $reflectionClass = new \ReflectionClass($class); - } + $class = $this->getClassDiscriminatorResolvedClass($data, $class); + $reflectionClass = new \ReflectionClass($class); $constructor = $this->getConstructor($data, $class, $context, $reflectionClass, $allowedAttributes); if ($constructor) { @@ -295,6 +287,24 @@ protected function instantiateObject(array &$data, $class, array &$context, \Ref return new $class(); } + protected function getClassDiscriminatorResolvedClass(array &$data, string $class): string + { + if (null === $this->classDiscriminatorResolver || (null === $mapping = $this->classDiscriminatorResolver->getMappingForClass($class))) { + return $class; + } + + if (!isset($data[$mapping->getTypeProperty()])) { + throw new RuntimeException(sprintf('Type property "%s" not found for the abstract object "%s"', $mapping->getTypeProperty(), $class)); + } + + $type = $data[$mapping->getTypeProperty()]; + if (null === ($mappedClass = $mapping->getClassForType($type))) { + throw new RuntimeException(sprintf('The type "%s" has no mapped class for the abstract object "%s"', $type, $class)); + } + + return $mappedClass; + } + /** * {@inheritdoc} */ @@ -494,7 +504,6 @@ protected function createRelationSerializationContext(string $resourceClass, arr /** * {@inheritdoc} * - * @throws NoSuchPropertyException * @throws UnexpectedValueException * @throws LogicException */ @@ -503,6 +512,7 @@ protected function getAttributeValue($object, $attribute, $format = null, array $context['api_attribute'] = $attribute; $propertyMetadata = $this->propertyMetadataFactory->create($context['resource_class'], $attribute, $this->getFactoryOptions($context)); + // BC to be removed in 3.0 try { $attributeValue = $this->propertyAccessor->getValue($object, $attribute); } catch (NoSuchPropertyException $e) { diff --git a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php index 84e0867ce47..266b6ad1c26 100644 --- a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php +++ b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php @@ -922,12 +922,10 @@ private function getPartialContainerBuilderProphecy($configuration = null) 'api_platform.listener.view.write', 'api_platform.metadata.extractor.xml', 'api_platform.metadata.property.metadata_factory.cached', - 'api_platform.metadata.property.metadata_factory.inherited', 'api_platform.metadata.property.metadata_factory.property_info', 'api_platform.metadata.property.metadata_factory.serializer', 'api_platform.metadata.property.metadata_factory.xml', 'api_platform.metadata.property.name_collection_factory.cached', - 'api_platform.metadata.property.name_collection_factory.inherited', 'api_platform.metadata.property.name_collection_factory.property_info', 'api_platform.metadata.property.name_collection_factory.xml', 'api_platform.metadata.resource.metadata_factory.cached', diff --git a/tests/Fixtures/TestBundle/Resources/config/serialization/abstract_dummy.yml b/tests/Fixtures/TestBundle/Resources/config/serialization/abstract_dummy.yml index a6f77e6325f..72d4b1ac29c 100644 --- a/tests/Fixtures/TestBundle/Resources/config/serialization/abstract_dummy.yml +++ b/tests/Fixtures/TestBundle/Resources/config/serialization/abstract_dummy.yml @@ -1,11 +1,11 @@ ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\AbstractDummy: - discriminator_map: - type_property: discr - mapping: - concrete: 'ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\ConcreteDummy' + discriminator_map: + type_property: discr + mapping: + concrete: ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\ConcreteDummy ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\AbstractDummy: - discriminator_map: - type_property: discr - mapping: - concrete: 'ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\ConcreteDummy' \ No newline at end of file + discriminator_map: + type_property: discr + mapping: + concrete: ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\ConcreteDummy diff --git a/tests/JsonApi/Serializer/ItemNormalizerTest.php b/tests/JsonApi/Serializer/ItemNormalizerTest.php index 16ef48ef592..194fa0514d2 100644 --- a/tests/JsonApi/Serializer/ItemNormalizerTest.php +++ b/tests/JsonApi/Serializer/ItemNormalizerTest.php @@ -95,7 +95,72 @@ public function testSupportNormalization() $this->assertFalse($normalizer->supportsNormalization($std, ItemNormalizer::FORMAT)); } - public function testNormalize() + public function testNormalize(): void + { + $dummy = new Dummy(); + $dummy->setId(10); + $dummy->setName('hello'); + + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(Dummy::class, [])->willReturn(new PropertyNameCollection(['id', 'name', '\bad_property'])); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', [])->willReturn(new PropertyMetadata(null, null, true)); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'id', [])->willReturn(new PropertyMetadata(null, null, true, null, null, null, null, true)); + $propertyMetadataFactoryProphecy->create(Dummy::class, '\bad_property', [])->willReturn(new PropertyMetadata(null, null, true)); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getIriFromItem($dummy)->willReturn('/dummies/10'); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass($dummy, null)->willReturn(Dummy::class); + $resourceClassResolverProphecy->getResourceClass($dummy, Dummy::class)->willReturn(Dummy::class); + + $propertyAccessorProphecy = $this->prophesize(PropertyAccessorInterface::class); + $propertyAccessorProphecy->getValue($dummy, 'id')->willReturn(10); + $propertyAccessorProphecy->getValue($dummy, 'name')->willReturn('hello'); + + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataFactoryProphecy->create(Dummy::class)->willReturn(new ResourceMetadata('Dummy', 'A dummy', '/dummy', null, null, ['id', 'name'])); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->willImplement(NormalizerInterface::class); + $serializerProphecy->normalize('hello', ItemNormalizer::FORMAT, Argument::type('array'))->willReturn('hello'); + $serializerProphecy->normalize(10, ItemNormalizer::FORMAT, Argument::type('array'))->willReturn(10); + $serializerProphecy->normalize(null, ItemNormalizer::FORMAT, Argument::type('array'))->willReturn(null); + + $normalizer = new ItemNormalizer( + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal(), + $propertyAccessorProphecy->reveal(), + new ReservedAttributeNameConverter(), + $resourceMetadataFactoryProphecy->reveal(), + [], + [] + ); + + $normalizer->setSerializer($serializerProphecy->reveal()); + + $expected = [ + 'data' => [ + 'type' => 'Dummy', + 'id' => '/dummies/10', + 'attributes' => [ + '_id' => 10, + 'name' => 'hello', + ], + ], + ]; + + $this->assertEquals($expected, $normalizer->normalize($dummy, ItemNormalizer::FORMAT)); + } + + /** + * @group legacy + */ + public function testNormalizeChildInheritedProperty(): void { $dummy = new Dummy(); $dummy->setId(10); diff --git a/tests/Metadata/Property/Factory/InheritedPropertyMetadataFactoryTest.php b/tests/Metadata/Property/Factory/InheritedPropertyMetadataFactoryTest.php index 2496e7880aa..0788fd0cbde 100644 --- a/tests/Metadata/Property/Factory/InheritedPropertyMetadataFactoryTest.php +++ b/tests/Metadata/Property/Factory/InheritedPropertyMetadataFactoryTest.php @@ -24,7 +24,7 @@ use Symfony\Component\PropertyInfo\Type; /** - * @author Antoine Bluchet + * @group legacy */ class InheritedPropertyMetadataFactoryTest extends TestCase { diff --git a/tests/Metadata/Property/Factory/InheritedPropertyNameCollectionFactoryTest.php b/tests/Metadata/Property/Factory/InheritedPropertyNameCollectionFactoryTest.php index c732b84095b..9660ad1841b 100644 --- a/tests/Metadata/Property/Factory/InheritedPropertyNameCollectionFactoryTest.php +++ b/tests/Metadata/Property/Factory/InheritedPropertyNameCollectionFactoryTest.php @@ -23,7 +23,7 @@ use PHPUnit\Framework\TestCase; /** - * @author Antoine Bluchet + * @group legacy */ class InheritedPropertyNameCollectionFactoryTest extends TestCase { diff --git a/tests/Metadata/Property/Factory/SerializerPropertyMetadataFactoryTest.php b/tests/Metadata/Property/Factory/SerializerPropertyMetadataFactoryTest.php index 39aafee3365..42b0e0f900d 100644 --- a/tests/Metadata/Property/Factory/SerializerPropertyMetadataFactoryTest.php +++ b/tests/Metadata/Property/Factory/SerializerPropertyMetadataFactoryTest.php @@ -30,9 +30,6 @@ use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface as SerializerClassMetadataFactoryInterface; use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; -/** - * @author Teoh Han Hui - */ class SerializerPropertyMetadataFactoryTest extends TestCase { public function testConstruct() @@ -137,7 +134,10 @@ public function groupsProvider(): array ]; } - public function testCreateInherited() + /** + * @group legacy + */ + public function testCreateInherited(): void { $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); $resourceMetadataFactoryProphecy->create(DummyTableInheritanceChild::class)->willReturn(new ResourceMetadata()); diff --git a/tests/Serializer/AbstractItemNormalizerTest.php b/tests/Serializer/AbstractItemNormalizerTest.php index 347a2548acd..e10fbf07063 100644 --- a/tests/Serializer/AbstractItemNormalizerTest.php +++ b/tests/Serializer/AbstractItemNormalizerTest.php @@ -805,7 +805,10 @@ public function testNullable() $propertyAccessorProphecy->setValue($actual, 'name', null)->shouldHaveBeenCalled(); } - public function testChildInheritedProperty() + /** + * @group legacy + */ + public function testChildInheritedProperty(): void { $dummy = new DummyTableInheritance(); $dummy->setName('foo'); From b9e3328d01caa9057ead190f5e5b0bf6dfac2cf9 Mon Sep 17 00:00:00 2001 From: adev Date: Sun, 16 Dec 2018 20:02:52 +0100 Subject: [PATCH 018/160] Use default value of PHP properties to Swagger doc Add default and example information to swagger context if php properties have default values so the exemple documentation section use these values. Close #2363 --- .../Resources/config/metadata/metadata.xml | 4 + .../DefaultPropertyMetadataFactory.php | 60 ++++++++++++++ .../ApiPlatformExtensionTest.php | 1 + .../Entity/DummyPropertyWithDefaultValue.php | 59 ++++++++++++++ .../DefaultPropertyMetadataFactoryTest.php | 80 +++++++++++++++++++ 5 files changed, 204 insertions(+) create mode 100644 src/Metadata/Property/Factory/DefaultPropertyMetadataFactory.php create mode 100644 tests/Fixtures/TestBundle/Entity/DummyPropertyWithDefaultValue.php create mode 100644 tests/Metadata/Property/Factory/DefaultPropertyMetadataFactoryTest.php diff --git a/src/Bridge/Symfony/Bundle/Resources/config/metadata/metadata.xml b/src/Bridge/Symfony/Bundle/Resources/config/metadata/metadata.xml index 9f07bea8fc3..3efa9dd56b2 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/metadata/metadata.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/metadata/metadata.xml @@ -74,6 +74,10 @@ + + + + diff --git a/src/Metadata/Property/Factory/DefaultPropertyMetadataFactory.php b/src/Metadata/Property/Factory/DefaultPropertyMetadataFactory.php new file mode 100644 index 00000000000..c7995b59fb1 --- /dev/null +++ b/src/Metadata/Property/Factory/DefaultPropertyMetadataFactory.php @@ -0,0 +1,60 @@ + + * + * 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\Property\Factory; + +use ApiPlatform\Core\Exception\PropertyNotFoundException; +use ApiPlatform\Core\Metadata\Property\PropertyMetadata; + +class DefaultPropertyMetadataFactory implements PropertyMetadataFactoryInterface +{ + private $decorated; + + public function __construct(PropertyMetadataFactoryInterface $decorated = null) + { + $this->decorated = $decorated; + } + + public function create(string $resourceClass, string $property, array $options = []): PropertyMetadata + { + if (null === $this->decorated) { + $propertyMetadata = new PropertyMetadata(); + } else { + try { + $propertyMetadata = $this->decorated->create($resourceClass, $property, $options); + } catch (PropertyNotFoundException $propertyNotFoundException) { + $propertyMetadata = new PropertyMetadata(); + } + } + + try { + $reflectionClass = new \ReflectionClass($resourceClass); + } catch (\ReflectionException $reflectionException) { + return $propertyMetadata; + } + + $defaultProperties = $reflectionClass->getDefaultProperties(); + + if (array_key_exists($property, $defaultProperties) && null !== ($defaultProperty = $defaultProperties[$property])) { + $attributes = $propertyMetadata->getAttributes() ?? []; + $attributes['swagger_context']['default'] = $defaultProperty; + if (!isset($attributes['swagger_context']['example'])) { + $attributes['swagger_context']['example'] = $defaultProperty; + } + + return $propertyMetadata->withAttributes($attributes); + } + + return $propertyMetadata; + } +} diff --git a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php index 266b6ad1c26..e9034147400 100644 --- a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php +++ b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php @@ -936,6 +936,7 @@ private function getPartialContainerBuilderProphecy($configuration = null) 'api_platform.metadata.resource.metadata_factory.xml', 'api_platform.metadata.resource.name_collection_factory.cached', 'api_platform.metadata.resource.name_collection_factory.xml', + 'api_platform.metadata.property.metadata_factory.default_property', 'api_platform.negotiator', 'api_platform.operation_method_resolver', 'api_platform.operation_path_resolver.custom', diff --git a/tests/Fixtures/TestBundle/Entity/DummyPropertyWithDefaultValue.php b/tests/Fixtures/TestBundle/Entity/DummyPropertyWithDefaultValue.php new file mode 100644 index 00000000000..b74300b1768 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/DummyPropertyWithDefaultValue.php @@ -0,0 +1,59 @@ + + * + * 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\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Core\Annotation\ApiResource; +use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Serializer\Annotation\Groups; + +/** + * DummyPropertyWithDefaultValue. + * + * @ORM\Entity + * + * @ApiResource(attributes={ + * "normalization_context"={"groups"={"dummy_read"}}, + * "denormalization_context"={"groups"={"dummy_write"}} + * }) + */ +class DummyPropertyWithDefaultValue +{ + /** + * @var int + * + * @ORM\Id + * @ORM\Column(type="integer") + * @ORM\GeneratedValue(strategy="AUTO") + * + * @Groups("dummy_read") + */ + private $id; + + /** + * @var string + * + * @ORM\Column(nullable=true) + * + * @Groups({"dummy_read", "dummy_write"}) + */ + public $foo = 'foo'; + + /** + * @return int + */ + public function getId() + { + return $this->id; + } +} diff --git a/tests/Metadata/Property/Factory/DefaultPropertyMetadataFactoryTest.php b/tests/Metadata/Property/Factory/DefaultPropertyMetadataFactoryTest.php new file mode 100644 index 00000000000..9eb416e9643 --- /dev/null +++ b/tests/Metadata/Property/Factory/DefaultPropertyMetadataFactoryTest.php @@ -0,0 +1,80 @@ + + * + * 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\Tests\Metadata\Property\Factory; + +use ApiPlatform\Core\Exception\PropertyNotFoundException; +use ApiPlatform\Core\Metadata\Property\Factory\DefaultPropertyMetadataFactory; +use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Property\PropertyMetadata; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyPropertyWithDefaultValue; +use PHPUnit\Framework\TestCase; + +class DefaultPropertyMetadataFactoryTest extends TestCase +{ + public function testCreate() + { + $factory = new DefaultPropertyMetadataFactory(); + $metadata = $factory->create(DummyPropertyWithDefaultValue::class, 'foo'); + + $shouldBe = [ + 'swagger_context' => [ + 'default' => 'foo', + 'example' => 'foo', + ], + ]; + $this->assertEquals($metadata->getAttributes(), $shouldBe); + } + + public function testCreateShouldNotOverrideExampleIfAlreadyPresent() + { + $decoratedReturnProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $decoratedReturnProphecy->create(DummyPropertyWithDefaultValue::class, 'foo', []) + ->willReturn(new PropertyMetadata(null, 'A dummy', true, true, null, null, false, false, null, null, [ + 'swagger_context' => [ + 'example' => 'foo example', + ], + ])) + ->shouldBeCalled(); + + $factory = new DefaultPropertyMetadataFactory($decoratedReturnProphecy->reveal()); + $metadata = $factory->create(DummyPropertyWithDefaultValue::class, 'foo'); + + $shouldBe = [ + 'swagger_context' => [ + 'default' => 'foo', + 'example' => 'foo example', + ], + ]; + $this->assertEquals($metadata->getAttributes(), $shouldBe); + } + + public function testClassDoesNotExist() + { + $factory = new DefaultPropertyMetadataFactory(); + $metadata = $factory->create('\DoNotExist', 'foo'); + + $this->assertEquals(new PropertyMetadata(), $metadata); + } + + public function testPropertyDoesNotExist() + { + $decoratedProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $decoratedProphecy->create(DummyPropertyWithDefaultValue::class, 'doNotExist', [])->willThrow(new PropertyNotFoundException()); + + $factory = new DefaultPropertyMetadataFactory($decoratedProphecy->reveal()); + $metadata = $factory->create(DummyPropertyWithDefaultValue::class, 'doNotExist'); + + $this->assertEquals(new PropertyMetadata(), $metadata); + } +} From 2ac0d66769f47056c4919f8b0947143bab42e0e4 Mon Sep 17 00:00:00 2001 From: adev Date: Thu, 20 Dec 2018 22:34:32 +0100 Subject: [PATCH 019/160] Add default and example properties to ApiProperty --- src/Annotation/ApiProperty.php | 10 +++ .../AnnotationPropertyMetadataFactory.php | 8 ++- .../DefaultPropertyMetadataFactory.php | 17 ++--- src/Metadata/Property/PropertyMetadata.php | 50 +++++++++++++- .../DefaultPropertyMetadataFactoryTest.php | 31 +-------- .../Property/PropertyMetadataTest.php | 8 +++ .../DocumentationNormalizerV2Test.php | 66 +++++++++++++++++++ 7 files changed, 147 insertions(+), 43 deletions(-) diff --git a/src/Annotation/ApiProperty.php b/src/Annotation/ApiProperty.php index 2ec25e3545a..18e6236e6c1 100644 --- a/src/Annotation/ApiProperty.php +++ b/src/Annotation/ApiProperty.php @@ -76,6 +76,16 @@ final class ApiProperty */ public $identifier; + /** + * @var string + */ + public $default; + + /** + * @var string + */ + public $example; + /** * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 * diff --git a/src/Metadata/Property/Factory/AnnotationPropertyMetadataFactory.php b/src/Metadata/Property/Factory/AnnotationPropertyMetadataFactory.php index 9f882eb9cac..9362aa5d6e2 100644 --- a/src/Metadata/Property/Factory/AnnotationPropertyMetadataFactory.php +++ b/src/Metadata/Property/Factory/AnnotationPropertyMetadataFactory.php @@ -112,12 +112,16 @@ private function createMetadata(ApiProperty $annotation, PropertyMetadata $paren $annotation->identifier, $annotation->iri, null, - $annotation->attributes + $annotation->attributes, + null, + null, + $annotation->default, + $annotation->example ); } $propertyMetadata = $parentPropertyMetadata; - foreach ([['get', 'description'], ['is', 'readable'], ['is', 'writable'], ['is', 'readableLink'], ['is', 'writableLink'], ['is', 'required'], ['get', 'iri'], ['is', 'identifier'], ['get', 'attributes']] as $property) { + foreach ([['get', 'description'], ['is', 'readable'], ['is', 'writable'], ['is', 'readableLink'], ['is', 'writableLink'], ['is', 'required'], ['get', 'iri'], ['is', 'identifier'], ['get', 'attributes'], ['get', 'default'], ['get', 'example']] as $property) { if (null !== $value = $annotation->{$property[1]}) { $propertyMetadata = $this->createWith($propertyMetadata, $property, $value); } diff --git a/src/Metadata/Property/Factory/DefaultPropertyMetadataFactory.php b/src/Metadata/Property/Factory/DefaultPropertyMetadataFactory.php index c7995b59fb1..5f81481cf36 100644 --- a/src/Metadata/Property/Factory/DefaultPropertyMetadataFactory.php +++ b/src/Metadata/Property/Factory/DefaultPropertyMetadataFactory.php @@ -16,7 +16,10 @@ use ApiPlatform\Core\Exception\PropertyNotFoundException; use ApiPlatform\Core\Metadata\Property\PropertyMetadata; -class DefaultPropertyMetadataFactory implements PropertyMetadataFactoryInterface +/** + * Populates defaults values of the ressource properties using the default PHP values of properties. + */ +final class DefaultPropertyMetadataFactory implements PropertyMetadataFactoryInterface { private $decorated; @@ -45,16 +48,10 @@ public function create(string $resourceClass, string $property, array $options = $defaultProperties = $reflectionClass->getDefaultProperties(); - if (array_key_exists($property, $defaultProperties) && null !== ($defaultProperty = $defaultProperties[$property])) { - $attributes = $propertyMetadata->getAttributes() ?? []; - $attributes['swagger_context']['default'] = $defaultProperty; - if (!isset($attributes['swagger_context']['example'])) { - $attributes['swagger_context']['example'] = $defaultProperty; - } - - return $propertyMetadata->withAttributes($attributes); + if (!array_key_exists($property, $defaultProperties) || null === ($defaultProperty = $defaultProperties[$property])) { + return $propertyMetadata; } - return $propertyMetadata; + return $propertyMetadata->withDefault($defaultProperty); } } diff --git a/src/Metadata/Property/PropertyMetadata.php b/src/Metadata/Property/PropertyMetadata.php index 6919a30b611..1018d9726ec 100644 --- a/src/Metadata/Property/PropertyMetadata.php +++ b/src/Metadata/Property/PropertyMetadata.php @@ -38,8 +38,16 @@ final class PropertyMetadata private $attributes; private $subresource; private $initializable; + /** + * @var null + */ + private $default; + /** + * @var null + */ + private $example; - public function __construct(Type $type = null, string $description = null, bool $readable = null, bool $writable = null, bool $readableLink = null, bool $writableLink = null, bool $required = null, bool $identifier = null, string $iri = null, $childInherited = null, array $attributes = null, SubresourceMetadata $subresource = null, bool $initializable = null) + public function __construct(Type $type = null, string $description = null, bool $readable = null, bool $writable = null, bool $readableLink = null, bool $writableLink = null, bool $required = null, bool $identifier = null, string $iri = null, $childInherited = null, array $attributes = null, SubresourceMetadata $subresource = null, bool $initializable = null, $default = null, $example = null) { $this->type = $type; $this->description = $description; @@ -57,6 +65,8 @@ public function __construct(Type $type = null, string $description = null, bool $this->attributes = $attributes; $this->subresource = $subresource; $this->initializable = $initializable; + $this->default = $default; + $this->example = $example; } /** @@ -349,4 +359,42 @@ public function withInitializable(bool $initializable): self return $metadata; } + + /** + * Returns the default value of the property or NULL if the property doesn't have a default value. + */ + public function getDefault() + { + return $this->default; + } + + /** + * Returns a new instance with the given default value for the property. + */ + public function withDefault($default): self + { + $metadata = clone $this; + $metadata->default = $default; + + return $metadata; + } + + /** + * Returns an example of the value of the property. + */ + public function getExample() + { + return $this->example; + } + + /** + * Returns a new instance with the given example. + */ + public function withExample($example): self + { + $metadata = clone $this; + $metadata->example = $example; + + return $metadata; + } } diff --git a/tests/Metadata/Property/Factory/DefaultPropertyMetadataFactoryTest.php b/tests/Metadata/Property/Factory/DefaultPropertyMetadataFactoryTest.php index 9eb416e9643..4ad92c7a59f 100644 --- a/tests/Metadata/Property/Factory/DefaultPropertyMetadataFactoryTest.php +++ b/tests/Metadata/Property/Factory/DefaultPropertyMetadataFactoryTest.php @@ -27,36 +27,7 @@ public function testCreate() $factory = new DefaultPropertyMetadataFactory(); $metadata = $factory->create(DummyPropertyWithDefaultValue::class, 'foo'); - $shouldBe = [ - 'swagger_context' => [ - 'default' => 'foo', - 'example' => 'foo', - ], - ]; - $this->assertEquals($metadata->getAttributes(), $shouldBe); - } - - public function testCreateShouldNotOverrideExampleIfAlreadyPresent() - { - $decoratedReturnProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $decoratedReturnProphecy->create(DummyPropertyWithDefaultValue::class, 'foo', []) - ->willReturn(new PropertyMetadata(null, 'A dummy', true, true, null, null, false, false, null, null, [ - 'swagger_context' => [ - 'example' => 'foo example', - ], - ])) - ->shouldBeCalled(); - - $factory = new DefaultPropertyMetadataFactory($decoratedReturnProphecy->reveal()); - $metadata = $factory->create(DummyPropertyWithDefaultValue::class, 'foo'); - - $shouldBe = [ - 'swagger_context' => [ - 'default' => 'foo', - 'example' => 'foo example', - ], - ]; - $this->assertEquals($metadata->getAttributes(), $shouldBe); + $this->assertEquals($metadata->getDefault(), 'foo'); } public function testClassDoesNotExist() diff --git a/tests/Metadata/Property/PropertyMetadataTest.php b/tests/Metadata/Property/PropertyMetadataTest.php index afee7f6adb6..d1c0d0a3add 100644 --- a/tests/Metadata/Property/PropertyMetadataTest.php +++ b/tests/Metadata/Property/PropertyMetadataTest.php @@ -82,6 +82,14 @@ public function testValueObject() $newMetadata = $metadata->withInitializable(true); $this->assertNotSame($metadata, $newMetadata); $this->assertTrue($newMetadata->isInitializable()); + + $newMetadata = $metadata->withDefault('foobar'); + $this->assertNotSame($metadata, $newMetadata); + $this->assertEquals('foobar', $newMetadata->getDefault()); + + $newMetadata = $metadata->withExample('foobarexample'); + $this->assertNotSame($metadata, $newMetadata); + $this->assertEquals('foobarexample', $newMetadata->getExample()); } public function testShouldReturnRequiredFalseWhenRequiredTrueIsSetButMaskedByWritableFalse() diff --git a/tests/Swagger/Serializer/DocumentationNormalizerV2Test.php b/tests/Swagger/Serializer/DocumentationNormalizerV2Test.php index b8e1b605ae9..fbd8bdd0b9f 100644 --- a/tests/Swagger/Serializer/DocumentationNormalizerV2Test.php +++ b/tests/Swagger/Serializer/DocumentationNormalizerV2Test.php @@ -45,6 +45,7 @@ use ApiPlatform\Core\Tests\Fixtures\TestBundle\Dto\OutputDto; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Answer; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyPropertyWithDefaultValue; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Question; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\RelatedDummy; use PHPUnit\Framework\TestCase; @@ -2934,4 +2935,69 @@ private function doTestNormalizeWithInputAndOutputClass(): void $this->assertEquals($expected, $normalizer->normalize($documentation, DocumentationNormalizer::FORMAT, ['base_url' => '/app_dev.php/'])); } + + /** + * @dataProvider propertyWithDefaultProvider + */ + public function testNormalizeWithDefaultProperty($expectedDefault, $expectedExample, PropertyMetadata $propertyMetadata) + { + $documentation = new Documentation(new ResourceNameCollection([DummyPropertyWithDefaultValue::class])); + + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(DummyPropertyWithDefaultValue::class, [])->shouldBeCalled()->willReturn(new PropertyNameCollection(['foo'])); + + $dummyMetadata = new ResourceMetadata('DummyPropertyWithDefaultValue', null, null, ['get' => ['method' => 'GET']]); + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataFactoryProphecy->create(DummyPropertyWithDefaultValue::class)->shouldBeCalled()->willReturn($dummyMetadata); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(DummyPropertyWithDefaultValue::class, 'foo')->shouldBeCalled()->willReturn($propertyMetadata); + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->isResourceClass(DummyPropertyWithDefaultValue::class)->willReturn(true); + + $operationMethodResolverProphecy = $this->prophesize(OperationMethodResolverInterface::class); + $operationMethodResolverProphecy->getItemOperationMethod(DummyPropertyWithDefaultValue::class, 'get')->shouldBeCalled()->willReturn('GET'); + + $operationPathResolver = new CustomOperationPathResolver(new OperationPathResolver(new UnderscorePathSegmentNameGenerator())); + + $normalizer = new DocumentationNormalizer( + $resourceMetadataFactoryProphecy->reveal(), + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $resourceClassResolverProphecy->reveal(), + $operationMethodResolverProphecy->reveal(), + $operationPathResolver + ); + + $result = $normalizer->normalize($documentation, DocumentationNormalizer::FORMAT); + + $this->assertEquals($expectedDefault, $result['definitions']['DummyPropertyWithDefaultValue']['properties']['foo']['default']); + $this->assertEquals($expectedExample, $result['definitions']['DummyPropertyWithDefaultValue']['properties']['foo']['example']); + } + + public function propertyWithDefaultProvider() + { + yield 'default should be use for the example if it is not defined' => [ + 'default name', + 'default name', + $this->createStringPropertyMetada('default name'), + ]; + + yield 'should use default and example if they are defined' => [ + 'default name', + 'example name', + $this->createStringPropertyMetada('default name', 'example name'), + ]; + + yield 'should use default and example from swagger context if they are defined' => [ + 'swagger default', + 'swagger example', + $this->createStringPropertyMetada('default name', 'example name', ['swagger_context' => ['default' => 'swagger default', 'example' => 'swagger example']]), + ]; + } + + protected function createStringPropertyMetada($default = null, $example = null, $attributes = []): PropertyMetadata + { + return new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING), null, true, true, true, true, false, false, null, null, $attributes, null, null, $default, $example); + } } From f3ddc2c0d7e2a212e9ee723ad6626abb83ae59fe Mon Sep 17 00:00:00 2001 From: adev Date: Mon, 7 Jan 2019 19:18:24 +0100 Subject: [PATCH 020/160] Fix phpdoc --- src/Annotation/ApiProperty.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Annotation/ApiProperty.php b/src/Annotation/ApiProperty.php index 18e6236e6c1..83b161e5ab3 100644 --- a/src/Annotation/ApiProperty.php +++ b/src/Annotation/ApiProperty.php @@ -77,12 +77,12 @@ final class ApiProperty public $identifier; /** - * @var string + * @var mixed */ public $default; /** - * @var string + * @var mixed */ public $example; From a92bab907ab799e315bb2fbdc8e71e0e59ba2d86 Mon Sep 17 00:00:00 2001 From: adev Date: Sat, 5 Oct 2019 17:13:05 +0200 Subject: [PATCH 021/160] Fix rebase --- src/JsonSchema/SchemaFactory.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/JsonSchema/SchemaFactory.php b/src/JsonSchema/SchemaFactory.php index dc70791dccc..daf2e3aa014 100644 --- a/src/JsonSchema/SchemaFactory.php +++ b/src/JsonSchema/SchemaFactory.php @@ -176,6 +176,18 @@ private function buildPropertySchema(Schema $schema, string $definitionName, str $propertySchema['externalDocs'] = ['url' => $iri]; } + if (!isset($propertySchema['default']) && null !== $default = $propertyMetadata->getDefault()) { + $propertySchema['default'] = $default; + } + + if (!isset($propertySchema['example']) && null !== $example = $propertyMetadata->getExample()) { + $propertySchema['example'] = $example; + } + + if (!isset($propertySchema['example']) && isset($propertySchema['default'])) { + $propertySchema['example'] = $propertySchema['default']; + } + $valueSchema = []; if (null !== $type = $propertyMetadata->getType()) { $isCollection = $type->isCollection(); From 41084b4983ece1978e1f24004b8de073eb69c032 Mon Sep 17 00:00:00 2001 From: adev Date: Sat, 5 Oct 2019 22:11:32 +0200 Subject: [PATCH 022/160] Fix deprecation notices --- .../Swagger/Serializer/DocumentationNormalizerV2Test.php | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/tests/Swagger/Serializer/DocumentationNormalizerV2Test.php b/tests/Swagger/Serializer/DocumentationNormalizerV2Test.php index fbd8bdd0b9f..47ef5a1edff 100644 --- a/tests/Swagger/Serializer/DocumentationNormalizerV2Test.php +++ b/tests/Swagger/Serializer/DocumentationNormalizerV2Test.php @@ -2952,11 +2952,6 @@ public function testNormalizeWithDefaultProperty($expectedDefault, $expectedExam $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); $propertyMetadataFactoryProphecy->create(DummyPropertyWithDefaultValue::class, 'foo')->shouldBeCalled()->willReturn($propertyMetadata); - $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); - $resourceClassResolverProphecy->isResourceClass(DummyPropertyWithDefaultValue::class)->willReturn(true); - - $operationMethodResolverProphecy = $this->prophesize(OperationMethodResolverInterface::class); - $operationMethodResolverProphecy->getItemOperationMethod(DummyPropertyWithDefaultValue::class, 'get')->shouldBeCalled()->willReturn('GET'); $operationPathResolver = new CustomOperationPathResolver(new OperationPathResolver(new UnderscorePathSegmentNameGenerator())); @@ -2964,8 +2959,8 @@ public function testNormalizeWithDefaultProperty($expectedDefault, $expectedExam $resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), - $resourceClassResolverProphecy->reveal(), - $operationMethodResolverProphecy->reveal(), + null, + null, $operationPathResolver ); From c1c5ea5e4bf30241c718541cfbda8a187092f018 Mon Sep 17 00:00:00 2001 From: adev Date: Sat, 5 Oct 2019 22:16:09 +0200 Subject: [PATCH 023/160] Fix phpcs --- .../Property/Factory/DefaultPropertyMetadataFactory.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Metadata/Property/Factory/DefaultPropertyMetadataFactory.php b/src/Metadata/Property/Factory/DefaultPropertyMetadataFactory.php index 5f81481cf36..9c951077753 100644 --- a/src/Metadata/Property/Factory/DefaultPropertyMetadataFactory.php +++ b/src/Metadata/Property/Factory/DefaultPropertyMetadataFactory.php @@ -48,7 +48,7 @@ public function create(string $resourceClass, string $property, array $options = $defaultProperties = $reflectionClass->getDefaultProperties(); - if (!array_key_exists($property, $defaultProperties) || null === ($defaultProperty = $defaultProperties[$property])) { + if (!\array_key_exists($property, $defaultProperties) || null === ($defaultProperty = $defaultProperties[$property])) { return $propertyMetadata; } From 39987fcf10a5278d7e8456de1dcf8eddc74566c5 Mon Sep 17 00:00:00 2001 From: adev Date: Sat, 5 Oct 2019 22:39:05 +0200 Subject: [PATCH 024/160] Fix phpstan error --- tests/Swagger/Serializer/DocumentationNormalizerV2Test.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/Swagger/Serializer/DocumentationNormalizerV2Test.php b/tests/Swagger/Serializer/DocumentationNormalizerV2Test.php index 47ef5a1edff..c298da54a6c 100644 --- a/tests/Swagger/Serializer/DocumentationNormalizerV2Test.php +++ b/tests/Swagger/Serializer/DocumentationNormalizerV2Test.php @@ -2966,6 +2966,7 @@ public function testNormalizeWithDefaultProperty($expectedDefault, $expectedExam $result = $normalizer->normalize($documentation, DocumentationNormalizer::FORMAT); + $this->assertIsArray($result); $this->assertEquals($expectedDefault, $result['definitions']['DummyPropertyWithDefaultValue']['properties']['foo']['default']); $this->assertEquals($expectedExample, $result['definitions']['DummyPropertyWithDefaultValue']['properties']['foo']['example']); } From a83c1184dad6655919eb9fc4b224860b1e13d14d Mon Sep 17 00:00:00 2001 From: adev Date: Sun, 20 Oct 2019 16:16:26 +0200 Subject: [PATCH 025/160] Use the Doctrine default value as default value in Swagger doc --- .../DoctrineOrmPropertyMetadataFactory.php | 7 +++++ ...DoctrineOrmPropertyMetadataFactoryTest.php | 28 +++++++++++++++++++ .../Entity/DummyPropertyWithDefaultValue.php | 7 +++++ 3 files changed, 42 insertions(+) diff --git a/src/Bridge/Doctrine/Orm/Metadata/Property/DoctrineOrmPropertyMetadataFactory.php b/src/Bridge/Doctrine/Orm/Metadata/Property/DoctrineOrmPropertyMetadataFactory.php index 1f326321aec..48bcff7e3d0 100644 --- a/src/Bridge/Doctrine/Orm/Metadata/Property/DoctrineOrmPropertyMetadataFactory.php +++ b/src/Bridge/Doctrine/Orm/Metadata/Property/DoctrineOrmPropertyMetadataFactory.php @@ -76,6 +76,13 @@ public function create(string $resourceClass, string $property, array $options = $propertyMetadata = $propertyMetadata->withIdentifier(false); } + if ($doctrineClassMetadata instanceof ClassMetadataInfo && \in_array($property, $doctrineClassMetadata->getFieldNames(), true)) { + $fieldMapping = $doctrineClassMetadata->getFieldMapping($property); + if (\array_key_exists('options', $fieldMapping) && \array_key_exists('default', $fieldMapping['options'])) { + $propertyMetadata = $propertyMetadata->withDefault($fieldMapping['options']['default']); + } + } + return $propertyMetadata; } } diff --git a/tests/Bridge/Doctrine/Orm/Metadata/Property/DoctrineOrmPropertyMetadataFactoryTest.php b/tests/Bridge/Doctrine/Orm/Metadata/Property/DoctrineOrmPropertyMetadataFactoryTest.php index a12e29033b2..321f106c1b1 100644 --- a/tests/Bridge/Doctrine/Orm/Metadata/Property/DoctrineOrmPropertyMetadataFactoryTest.php +++ b/tests/Bridge/Doctrine/Orm/Metadata/Property/DoctrineOrmPropertyMetadataFactoryTest.php @@ -17,6 +17,7 @@ use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Property\PropertyMetadata; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyPropertyWithDefaultValue; use Doctrine\Common\Persistence\ManagerRegistry; use Doctrine\Common\Persistence\Mapping\ClassMetadata; use Doctrine\Common\Persistence\ObjectManager; @@ -73,6 +74,7 @@ public function testCreateIsWritable() $classMetadata = $this->prophesize(ClassMetadataInfo::class); $classMetadata->getIdentifier()->shouldBeCalled()->willReturn(['id']); + $classMetadata->getFieldNames()->shouldBeCalled()->willReturn([]); $objectManager = $this->prophesize(ObjectManager::class); $objectManager->getClassMetadata(Dummy::class)->shouldBeCalled()->willReturn($classMetadata->reveal()); @@ -88,6 +90,31 @@ public function testCreateIsWritable() $this->assertEquals($doctrinePropertyMetadata->isWritable(), false); } + public function testCreateWithDefaultOption() + { + $propertyMetadata = new PropertyMetadata(); + + $propertyMetadataFactory = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactory->create(DummyPropertyWithDefaultValue::class, 'dummyDefaultOption', [])->shouldBeCalled()->willReturn($propertyMetadata); + + $classMetadata = new ClassMetadataInfo(DummyPropertyWithDefaultValue::class); + $classMetadata->fieldMappings = [ + 'dummyDefaultOption' => ['options' => ['default' => 'default value']], + ]; + + $objectManager = $this->prophesize(ObjectManager::class); + $objectManager->getClassMetadata(DummyPropertyWithDefaultValue::class)->shouldBeCalled()->willReturn($classMetadata); + + $managerRegistry = $this->prophesize(ManagerRegistry::class); + $managerRegistry->getManagerForClass(DummyPropertyWithDefaultValue::class)->shouldBeCalled()->willReturn($objectManager->reveal()); + + $doctrineOrmPropertyMetadataFactory = new DoctrineOrmPropertyMetadataFactory($managerRegistry->reveal(), $propertyMetadataFactory->reveal()); + + $doctrinePropertyMetadata = $doctrineOrmPropertyMetadataFactory->create(DummyPropertyWithDefaultValue::class, 'dummyDefaultOption'); + + $this->assertEquals($doctrinePropertyMetadata->getDefault(), 'default value'); + } + public function testCreateClassMetadataInfo() { $propertyMetadata = new PropertyMetadata(); @@ -98,6 +125,7 @@ public function testCreateClassMetadataInfo() $classMetadata = $this->prophesize(ClassMetadataInfo::class); $classMetadata->getIdentifier()->shouldBeCalled()->willReturn(['id']); $classMetadata->isIdentifierNatural()->shouldBeCalled()->willReturn(true); + $classMetadata->getFieldNames()->shouldBeCalled()->willReturn([]); $objectManager = $this->prophesize(ObjectManager::class); $objectManager->getClassMetadata(Dummy::class)->shouldBeCalled()->willReturn($classMetadata->reveal()); diff --git a/tests/Fixtures/TestBundle/Entity/DummyPropertyWithDefaultValue.php b/tests/Fixtures/TestBundle/Entity/DummyPropertyWithDefaultValue.php index b74300b1768..2c7323165bd 100644 --- a/tests/Fixtures/TestBundle/Entity/DummyPropertyWithDefaultValue.php +++ b/tests/Fixtures/TestBundle/Entity/DummyPropertyWithDefaultValue.php @@ -49,6 +49,13 @@ class DummyPropertyWithDefaultValue */ public $foo = 'foo'; + /** + * @var string A dummy with a Doctrine default options + * + * @ORM\Column(options={"default"="default value"}) + */ + public $dummyDefaultOption; + /** * @return int */ From c859ad0011452bfdf462830638e1a3088027b4c5 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 29 Nov 2019 17:40:40 +0100 Subject: [PATCH 026/160] OpenAPI: Add PHP default values to the documentation (#2386) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc25a0d1eeb..1cf0ddccd09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ * MongoDB: Possibility to add execute options (aggregate command fields) for a resource, like `allowDiskUse` (#3144) * GraphQL: Allow to format GraphQL errors based on exceptions (#3063) * GraphQL: Add page-based pagination (#3175) +* OpenAPI: Add PHP default values to the documentation (#2386) ## 2.5.2 From e16d296550ba7eccd90cd9a4ff16044762940646 Mon Sep 17 00:00:00 2001 From: Julien Deniau Date: Tue, 20 Feb 2018 00:28:39 +0100 Subject: [PATCH 027/160] test `allowEmptyValue` in filter validation --- features/filter/filter_validation.feature | 10 +-- src/Filter/QueryParameterValidateListener.php | 67 ++++++++++++++++--- .../TestBundle/Entity/FilterValidator.php | 4 +- .../Filter/RequiredAllowEmptyFilter.php | 40 +++++++++++ tests/Fixtures/app/config/config_common.yml | 4 ++ 5 files changed, 110 insertions(+), 15 deletions(-) create mode 100644 tests/Fixtures/TestBundle/Filter/RequiredAllowEmptyFilter.php diff --git a/features/filter/filter_validation.feature b/features/filter/filter_validation.feature index ab85b45dd5d..258022fdfe0 100644 --- a/features/filter/filter_validation.feature +++ b/features/filter/filter_validation.feature @@ -2,16 +2,18 @@ Feature: Validate filters based upon filter description @createSchema Scenario: Required filter should not throw an error if set - When I am on "/filter_validators?required=foo" + When I am on "/filter_validators?required=foo&required-allow-empty=&arrayRequired[foo]=" Then the response status code should be 200 - When I am on "/filter_validators?required=" - Then the response status code should be 200 + Scenario: Required filter that does not allow empty value should throw an error if empty + When I am on "/filter_validators?required=&required-allow-empty=&arrayRequired[foo]=" + Then the response status code should be 400 + And the JSON node "detail" should be equal to 'Query parameter "required" does not allow empty value' Scenario: Required filter should throw an error if not set When I am on "/filter_validators" Then the response status code should be 400 - And the JSON node "detail" should be equal to 'Query parameter "required" is required' + Then the JSON node "detail" should match '/^Query parameter "required" is required\nQuery parameter "required-allow-empty" is required$/' Scenario: Required filter should not throw an error if set When I am on "/array_filter_validators?arrayRequired[]=foo&indexedArrayRequired[foo]=foo" diff --git a/src/Filter/QueryParameterValidateListener.php b/src/Filter/QueryParameterValidateListener.php index c6583a02c44..7d6e39e77c0 100644 --- a/src/Filter/QueryParameterValidateListener.php +++ b/src/Filter/QueryParameterValidateListener.php @@ -60,13 +60,7 @@ public function onKernelRequest(RequestEvent $event): void } foreach ($filter->getDescription($attributes['resource_class']) as $name => $data) { - if (!($data['required'] ?? false)) { // property is not required - continue; - } - - if (!$this->isRequiredFilterValid($name, $request)) { - $errorList[] = sprintf('Query parameter "%s" is required', $name); - } + $errorList = $this->checkRequired($errorList, $name, $data, $request); } } @@ -75,10 +69,32 @@ public function onKernelRequest(RequestEvent $event): void } } + private function checkRequired(array $errorList, string $name, array $data, Request $request): array + { + // filter is not required, the `checkRequired` method can not break + if (!($data['required'] ?? false)) { + return $errorList; + } + + // if query param is not given, then break + if (!$this->requestHasQueryParameter($request, $name)) { + $errorList[] = sprintf('Query parameter "%s" is required', $name); + + return $errorList; + } + + // if query param is empty and the configuration does not allow it + if (!($data['swagger']['allowEmptyValue'] ?? false) && empty($this->requestGetQueryParameter($request, $name))) { + $errorList[] = sprintf('Query parameter "%s" does not allow empty value', $name); + } + + return $errorList; + } + /** - * Test if required filter is valid. It validates array notation too like "required[bar]". + * Test if request has required parameter. */ - private function isRequiredFilterValid(string $name, Request $request): bool + private function requestHasQueryParameter(Request $request, string $name): bool { $matches = []; parse_str($name, $matches); @@ -99,6 +115,37 @@ private function isRequiredFilterValid(string $name, Request $request): bool return \is_array($queryParameter) && isset($queryParameter[$keyName]); } - return null !== $request->query->get($rootName); + return $request->query->has($rootName); + } + + /** + * Test if required filter is valid. It validates array notation too like "required[bar]". + */ + private function requestGetQueryParameter(Request $request, string $name) + { + $matches = []; + parse_str($name, $matches); + if (!$matches) { + return null; + } + + $rootName = array_keys($matches)[0] ?? ''; + if (!$rootName) { + return null; + } + + if (\is_array($matches[$rootName])) { + $keyName = array_keys($matches[$rootName])[0]; + + $queryParameter = $request->query->get($rootName); + + if (\is_array($queryParameter) && isset($queryParameter[$keyName])) { + return $queryParameter[$keyName]; + } + + return null; + } + + return $request->query->get($rootName); } } diff --git a/tests/Fixtures/TestBundle/Entity/FilterValidator.php b/tests/Fixtures/TestBundle/Entity/FilterValidator.php index 118050a9b8f..ae1ca5a1110 100644 --- a/tests/Fixtures/TestBundle/Entity/FilterValidator.php +++ b/tests/Fixtures/TestBundle/Entity/FilterValidator.php @@ -15,6 +15,7 @@ use ApiPlatform\Core\Annotation\ApiProperty; use ApiPlatform\Core\Annotation\ApiResource; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter\RequiredAllowEmptyFilter; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter\RequiredFilter; use Doctrine\ORM\Mapping as ORM; @@ -25,7 +26,8 @@ * * @ApiResource(attributes={ * "filters"={ - * RequiredFilter::class + * RequiredFilter::class, + * RequiredAllowEmptyFilter::class * } * }) * @ORM\Entity diff --git a/tests/Fixtures/TestBundle/Filter/RequiredAllowEmptyFilter.php b/tests/Fixtures/TestBundle/Filter/RequiredAllowEmptyFilter.php new file mode 100644 index 00000000000..d0dc25882dd --- /dev/null +++ b/tests/Fixtures/TestBundle/Filter/RequiredAllowEmptyFilter.php @@ -0,0 +1,40 @@ + + * + * 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\Tests\Fixtures\TestBundle\Filter; + +use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\AbstractFilter; +use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use Doctrine\ORM\QueryBuilder; + +class RequiredAllowEmptyFilter extends AbstractFilter +{ + protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null) + { + } + + // This function is only used to hook in documentation generators (supported by Swagger and Hydra) + public function getDescription(string $resourceClass): array + { + return [ + 'required-allow-empty' => [ + 'property' => 'required-allow-empty', + 'type' => 'string', + 'required' => true, + 'swagger' => [ + 'allowEmptyValue' => true, + ], + ], + ]; + } +} diff --git a/tests/Fixtures/app/config/config_common.yml b/tests/Fixtures/app/config/config_common.yml index e4c861cbb4a..92a009625f9 100644 --- a/tests/Fixtures/app/config/config_common.yml +++ b/tests/Fixtures/app/config/config_common.yml @@ -141,6 +141,10 @@ services: arguments: ['@doctrine'] tags: ['api_platform.filter'] + ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter\RequiredAllowEmptyFilter: + arguments: [ '@doctrine' ] + tags: [ 'api_platform.filter' ] + ApiPlatform\Core\Tests\Fixtures\TestBundle\Controller\: resource: '../../TestBundle/Controller' tags: ['controller.service_arguments'] From 44009bb27ae1b6631faaa563a1b720e14008c1c2 Mon Sep 17 00:00:00 2001 From: Julien Deniau Date: Tue, 20 Feb 2018 01:28:46 +0100 Subject: [PATCH 028/160] add bounds filter validator --- features/filter/filter_validation.feature | 33 ++++++++++ src/Filter/QueryParameterValidateListener.php | 30 +++++++++ .../TestBundle/Entity/FilterValidator.php | 2 + .../TestBundle/Filter/BoundsFilter.php | 66 +++++++++++++++++++ tests/Fixtures/app/config/config_common.yml | 4 ++ 5 files changed, 135 insertions(+) create mode 100644 tests/Fixtures/TestBundle/Filter/BoundsFilter.php diff --git a/features/filter/filter_validation.feature b/features/filter/filter_validation.feature index 258022fdfe0..93fa4379b95 100644 --- a/features/filter/filter_validation.feature +++ b/features/filter/filter_validation.feature @@ -39,3 +39,36 @@ Feature: Validate filters based upon filter description When I am on "/array_filter_validators?arrayRequired[]=foo&indexedArrayRequired[bar]=bar" Then the response status code should be 400 And the JSON node "detail" should be equal to 'Query parameter "indexedArrayRequired[foo]" is required' + + Scenario: Test filter bounds: maximum + When I am on "/filter_validators?required=foo&required-allow-empty&maximum=10" + Then the response status code should be 200 + + When I am on "/filter_validators?required=foo&required-allow-empty&maximum=11" + Then the response status code should be 400 + And the JSON node "detail" should be equal to 'Query parameter "maximum" must be less than or equal to 10' + + Scenario: Test filter bounds: exclusiveMaximum + When I am on "/filter_validators?required=foo&required-allow-empty&exclusiveMaximum=9" + Then the response status code should be 200 + + When I am on "/filter_validators?required=foo&required-allow-empty&exclusiveMaximum=10" + Then the response status code should be 400 + And the JSON node "detail" should be equal to 'Query parameter "exclusiveMaximum" must be less than 10' + + Scenario: Test filter bounds: minimum + When I am on "/filter_validators?required=foo&required-allow-empty&minimum=5" + Then the response status code should be 200 + + When I am on "/filter_validators?required=foo&required-allow-empty&minimum=0" + Then the response status code should be 400 + And the JSON node "detail" should be equal to 'Query parameter "minimum" must be greater than or equal to 5' + + @dropSchema + Scenario: Test filter bounds: exclusiveMinimum + When I am on "/filter_validators?required=foo&required-allow-empty&exclusiveMinimum=6" + Then the response status code should be 200 + + When I am on "/filter_validators?required=foo&required-allow-empty&exclusiveMinimum=5" + Then the response status code should be 400 + And the JSON node "detail" should be equal to 'Query parameter "exclusiveMinimum" must be greater than 5' diff --git a/src/Filter/QueryParameterValidateListener.php b/src/Filter/QueryParameterValidateListener.php index 7d6e39e77c0..6647ce90dbd 100644 --- a/src/Filter/QueryParameterValidateListener.php +++ b/src/Filter/QueryParameterValidateListener.php @@ -61,6 +61,7 @@ public function onKernelRequest(RequestEvent $event): void foreach ($filter->getDescription($attributes['resource_class']) as $name => $data) { $errorList = $this->checkRequired($errorList, $name, $data, $request); + $errorList = $this->checkBounds($errorList, $name, $data, $request); } } @@ -148,4 +149,33 @@ private function requestGetQueryParameter(Request $request, string $name) return $request->query->get($rootName); } + + private function checkBounds(array $errorList, string $name, array $data, Request $request): array + { + $value = $request->query->get($name); + if (empty($value) && '0' !== $value) { + return $errorList; + } + + $maximum = $data['swagger']['maximum'] ?? null; + $minimum = $data['swagger']['minimum'] ?? null; + + if (null !== $maximum) { + if (($data['swagger']['exclusiveMaximum'] ?? false) && $value >= $maximum) { + $errorList[] = sprintf('Query parameter "%s" must be less than %s', $name, $maximum); + } elseif ($value > $maximum) { + $errorList[] = sprintf('Query parameter "%s" must be less than or equal to %s', $name, $maximum); + } + } + + if (null !== $minimum) { + if (($data['swagger']['exclusiveMinimum'] ?? false) && $value <= $minimum) { + $errorList[] = sprintf('Query parameter "%s" must be greater than %s', $name, $minimum); + } elseif ($value < $minimum) { + $errorList[] = sprintf('Query parameter "%s" must be greater than or equal to %s', $name, $minimum); + } + } + + return $errorList; + } } diff --git a/tests/Fixtures/TestBundle/Entity/FilterValidator.php b/tests/Fixtures/TestBundle/Entity/FilterValidator.php index ae1ca5a1110..613d42531bf 100644 --- a/tests/Fixtures/TestBundle/Entity/FilterValidator.php +++ b/tests/Fixtures/TestBundle/Entity/FilterValidator.php @@ -15,6 +15,7 @@ use ApiPlatform\Core\Annotation\ApiProperty; use ApiPlatform\Core\Annotation\ApiResource; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter\BoundsFilter; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter\RequiredAllowEmptyFilter; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter\RequiredFilter; use Doctrine\ORM\Mapping as ORM; @@ -26,6 +27,7 @@ * * @ApiResource(attributes={ * "filters"={ + * BoundsFilter::class, * RequiredFilter::class, * RequiredAllowEmptyFilter::class * } diff --git a/tests/Fixtures/TestBundle/Filter/BoundsFilter.php b/tests/Fixtures/TestBundle/Filter/BoundsFilter.php new file mode 100644 index 00000000000..4327e8377ea --- /dev/null +++ b/tests/Fixtures/TestBundle/Filter/BoundsFilter.php @@ -0,0 +1,66 @@ + + * + * 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\Tests\Fixtures\TestBundle\Filter; + +use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\AbstractFilter; +use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use Doctrine\ORM\QueryBuilder; + +class BoundsFilter extends AbstractFilter +{ + protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null) + { + } + + // This function is only used to hook in documentation generators (supported by Swagger and Hydra) + public function getDescription(string $resourceClass): array + { + return [ + 'maximum' => [ + 'property' => 'maximum', + 'type' => 'number', + 'required' => false, + 'swagger' => [ + 'maximum' => 10, + ], + ], + 'exclusiveMaximum' => [ + 'property' => 'maximum', + 'type' => 'number', + 'required' => false, + 'swagger' => [ + 'maximum' => 10, + 'exclusiveMaximum' => true, + ], + ], + 'minimum' => [ + 'property' => 'minimum', + 'type' => 'number', + 'required' => false, + 'swagger' => [ + 'minimum' => 5, + ], + ], + 'exclusiveMinimum' => [ + 'property' => 'exclusiveMinimum', + 'type' => 'number', + 'required' => false, + 'swagger' => [ + 'minimum' => 5, + 'exclusiveMinimum' => true, + ], + ], + ]; + } +} diff --git a/tests/Fixtures/app/config/config_common.yml b/tests/Fixtures/app/config/config_common.yml index 92a009625f9..16b072e686a 100644 --- a/tests/Fixtures/app/config/config_common.yml +++ b/tests/Fixtures/app/config/config_common.yml @@ -144,6 +144,10 @@ services: ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter\RequiredAllowEmptyFilter: arguments: [ '@doctrine' ] tags: [ 'api_platform.filter' ] + + ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter\BoundsFilter: + arguments: [ '@doctrine' ] + tags: [ 'api_platform.filter' ] ApiPlatform\Core\Tests\Fixtures\TestBundle\Controller\: resource: '../../TestBundle/Controller' From 7f4b1c8ddade048189f71db5144a7077f771c4cf Mon Sep 17 00:00:00 2001 From: Julien Deniau Date: Tue, 20 Feb 2018 17:39:00 +0100 Subject: [PATCH 029/160] check filter `maxLength` and `minLength` --- features/filter/filter_validation.feature | 22 ++++++++- src/Filter/QueryParameterValidateListener.php | 36 ++++++++++++++ .../TestBundle/Entity/FilterValidator.php | 2 + .../TestBundle/Filter/LengthFilter.php | 48 +++++++++++++++++++ tests/Fixtures/app/config/config_common.yml | 4 ++ 5 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 tests/Fixtures/TestBundle/Filter/LengthFilter.php diff --git a/features/filter/filter_validation.feature b/features/filter/filter_validation.feature index 93fa4379b95..086179dc424 100644 --- a/features/filter/filter_validation.feature +++ b/features/filter/filter_validation.feature @@ -64,7 +64,6 @@ Feature: Validate filters based upon filter description Then the response status code should be 400 And the JSON node "detail" should be equal to 'Query parameter "minimum" must be greater than or equal to 5' - @dropSchema Scenario: Test filter bounds: exclusiveMinimum When I am on "/filter_validators?required=foo&required-allow-empty&exclusiveMinimum=6" Then the response status code should be 200 @@ -72,3 +71,24 @@ Feature: Validate filters based upon filter description When I am on "/filter_validators?required=foo&required-allow-empty&exclusiveMinimum=5" Then the response status code should be 400 And the JSON node "detail" should be equal to 'Query parameter "exclusiveMinimum" must be greater than 5' + + Scenario: Test filter bounds: max length + When I am on "/filter_validators?required=foo&required-allow-empty&max-length-3=123" + Then the response status code should be 200 + + When I am on "/filter_validators?required=foo&required-allow-empty&max-length-3=1234" + Then the response status code should be 400 + And the JSON node "detail" should be equal to 'Query parameter "max-length-3" length must be lower than or equal to 3' + + Scenario: Do not throw an error if value is not an array + When I am on "/filter_validators?required=foo&required-allow-empty&max-length-3[]=12345" + Then the response status code should be 200 + + @dropSchema + Scenario: Test filter bounds: min length + When I am on "/filter_validators?required=foo&required-allow-empty&min-length-3=123" + Then the response status code should be 200 + + When I am on "/filter_validators?required=foo&required-allow-empty&min-length-3=12" + Then the response status code should be 400 + And the JSON node "detail" should be equal to 'Query parameter "min-length-3" length must be greater than or equal to 3' diff --git a/src/Filter/QueryParameterValidateListener.php b/src/Filter/QueryParameterValidateListener.php index 6647ce90dbd..7b0cae4c108 100644 --- a/src/Filter/QueryParameterValidateListener.php +++ b/src/Filter/QueryParameterValidateListener.php @@ -62,6 +62,7 @@ public function onKernelRequest(RequestEvent $event): void foreach ($filter->getDescription($attributes['resource_class']) as $name => $data) { $errorList = $this->checkRequired($errorList, $name, $data, $request); $errorList = $this->checkBounds($errorList, $name, $data, $request); + $errorList = $this->checkLength($errorList, $name, $data, $request); } } @@ -178,4 +179,39 @@ private function checkBounds(array $errorList, string $name, array $data, Reques return $errorList; } + + private function checkLength(array $errorList, string $name, array $data, Request $request): array + { + $maxLength = $data['swagger']['maxLength'] ?? null; + $minLength = $data['swagger']['minLength'] ?? null; + + $value = $request->query->get($name); + if (empty($value) && '0' !== $value || !\is_string($value)) { + return $errorList; + } + + // if (!is_string($value)) { + // $errorList[] = sprintf('Query parameter "%s" must be less than or equal to %s', $name, $maximum); + // return $errorList; + // } + + if (null !== $maxLength && mb_strlen($value) > $maxLength) { + $errorList[] = sprintf('Query parameter "%s" length must be lower than or equal to %s', $name, $maxLength); + } + + if (null !== $minLength && mb_strlen($value) < $minLength) { + $errorList[] = sprintf('Query parameter "%s" length must be greater than or equal to %s', $name, $minLength); + } + + return $errorList; + } + + // TODO grouper les filtres required dans une classe + // avoir deux entités, une required, une pour le reste + // pattern string See https://tools.ietf.org/html/draft-fge-json-schema-validation-00#section-5.2.3. + // maxItems integer See https://tools.ietf.org/html/draft-fge-json-schema-validation-00#section-5.3.2. + // minItems integer See https://tools.ietf.org/html/draft-fge-json-schema-validation-00#section-5.3.3. + // uniqueItems boolean See https://tools.ietf.org/html/draft-fge-json-schema-validation-00#section-5.3.4. + // enum [*] See https://tools.ietf.org/html/draft-fge-json-schema-validation-00#section-5.5.1. + // multipleOf } diff --git a/tests/Fixtures/TestBundle/Entity/FilterValidator.php b/tests/Fixtures/TestBundle/Entity/FilterValidator.php index 613d42531bf..e1a76feb51d 100644 --- a/tests/Fixtures/TestBundle/Entity/FilterValidator.php +++ b/tests/Fixtures/TestBundle/Entity/FilterValidator.php @@ -16,6 +16,7 @@ use ApiPlatform\Core\Annotation\ApiProperty; use ApiPlatform\Core\Annotation\ApiResource; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter\BoundsFilter; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter\LengthFilter; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter\RequiredAllowEmptyFilter; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter\RequiredFilter; use Doctrine\ORM\Mapping as ORM; @@ -28,6 +29,7 @@ * @ApiResource(attributes={ * "filters"={ * BoundsFilter::class, + * LengthFilter::class, * RequiredFilter::class, * RequiredAllowEmptyFilter::class * } diff --git a/tests/Fixtures/TestBundle/Filter/LengthFilter.php b/tests/Fixtures/TestBundle/Filter/LengthFilter.php new file mode 100644 index 00000000000..be9c30af0b2 --- /dev/null +++ b/tests/Fixtures/TestBundle/Filter/LengthFilter.php @@ -0,0 +1,48 @@ + + * + * 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\Tests\Fixtures\TestBundle\Filter; + +use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\AbstractFilter; +use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use Doctrine\ORM\QueryBuilder; + +class LengthFilter extends AbstractFilter +{ + protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null) + { + } + + // This function is only used to hook in documentation generators (supported by Swagger and Hydra) + public function getDescription(string $resourceClass): array + { + return [ + 'max-length-3' => [ + 'property' => 'max-length-3', + 'type' => 'string', + 'required' => false, + 'swagger' => [ + 'maxLength' => 3, + ], + ], + 'min-length-3' => [ + 'property' => 'min-length-3', + 'type' => 'string', + 'required' => false, + 'swagger' => [ + 'minLength' => 3, + ], + ], + ]; + } +} diff --git a/tests/Fixtures/app/config/config_common.yml b/tests/Fixtures/app/config/config_common.yml index 16b072e686a..ee301765248 100644 --- a/tests/Fixtures/app/config/config_common.yml +++ b/tests/Fixtures/app/config/config_common.yml @@ -148,6 +148,10 @@ services: ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter\BoundsFilter: arguments: [ '@doctrine' ] tags: [ 'api_platform.filter' ] + + ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter\LengthFilter: + arguments: [ '@doctrine' ] + tags: [ 'api_platform.filter' ] ApiPlatform\Core\Tests\Fixtures\TestBundle\Controller\: resource: '../../TestBundle/Controller' From e5136062490067c4a69785a2e1c353d0ec6cdff1 Mon Sep 17 00:00:00 2001 From: Julien Deniau Date: Wed, 21 Feb 2018 16:26:15 +0100 Subject: [PATCH 030/160] add lots of validations --- features/filter/filter_validation.feature | 27 +++++++++- src/Filter/QueryParameterValidateListener.php | 54 +++++++++++++++++-- .../TestBundle/Entity/FilterValidator.php | 6 +++ .../Fixtures/TestBundle/Filter/EnumFilter.php | 40 ++++++++++++++ .../TestBundle/Filter/MultipleOfFilter.php | 40 ++++++++++++++ .../TestBundle/Filter/PatternFilter.php | 40 ++++++++++++++ tests/Fixtures/app/config/config_common.yml | 12 +++++ 7 files changed, 215 insertions(+), 4 deletions(-) create mode 100644 tests/Fixtures/TestBundle/Filter/EnumFilter.php create mode 100644 tests/Fixtures/TestBundle/Filter/MultipleOfFilter.php create mode 100644 tests/Fixtures/TestBundle/Filter/PatternFilter.php diff --git a/features/filter/filter_validation.feature b/features/filter/filter_validation.feature index 086179dc424..77f1d7ff87d 100644 --- a/features/filter/filter_validation.feature +++ b/features/filter/filter_validation.feature @@ -84,7 +84,6 @@ Feature: Validate filters based upon filter description When I am on "/filter_validators?required=foo&required-allow-empty&max-length-3[]=12345" Then the response status code should be 200 - @dropSchema Scenario: Test filter bounds: min length When I am on "/filter_validators?required=foo&required-allow-empty&min-length-3=123" Then the response status code should be 200 @@ -92,3 +91,29 @@ Feature: Validate filters based upon filter description When I am on "/filter_validators?required=foo&required-allow-empty&min-length-3=12" Then the response status code should be 400 And the JSON node "detail" should be equal to 'Query parameter "min-length-3" length must be greater than or equal to 3' + + Scenario: Test filter pattern + When I am on "/filter_validators?required=foo&required-allow-empty&pattern=pattern" + When I am on "/filter_validators?required=foo&required-allow-empty&pattern=nrettap" + Then the response status code should be 200 + + When I am on "/filter_validators?required=foo&required-allow-empty&pattern=not-pattern" + Then the response status code should be 400 + And the JSON node "detail" should be equal to 'Query parameter "pattern" must match pattern /^(pattern|nrettap)$/' + + Scenario: Test filter enum + When I am on "/filter_validators?required=foo&required-allow-empty&enum=in-enum" + Then the response status code should be 200 + + When I am on "/filter_validators?required=foo&required-allow-empty&enum=not-in-enum" + Then the response status code should be 400 + And the JSON node "detail" should be equal to 'Query parameter "enum" must be one of "in-enum, mune-ni"' + + @dropSchema + Scenario: Test filter multipleOf + When I am on "/filter_validators?required=foo&required-allow-empty&multiple-of=4" + Then the response status code should be 200 + + When I am on "/filter_validators?required=foo&required-allow-empty&multiple-of=3" + Then the response status code should be 400 + And the JSON node "detail" should be equal to 'Query parameter "multiple-of" must multiple of 2' diff --git a/src/Filter/QueryParameterValidateListener.php b/src/Filter/QueryParameterValidateListener.php index 7b0cae4c108..c38e520ae16 100644 --- a/src/Filter/QueryParameterValidateListener.php +++ b/src/Filter/QueryParameterValidateListener.php @@ -63,6 +63,9 @@ public function onKernelRequest(RequestEvent $event): void $errorList = $this->checkRequired($errorList, $name, $data, $request); $errorList = $this->checkBounds($errorList, $name, $data, $request); $errorList = $this->checkLength($errorList, $name, $data, $request); + $errorList = $this->checkPattern($errorList, $name, $data, $request); + $errorList = $this->checkEnum($errorList, $name, $data, $request); + $errorList = $this->checkMultipleOf($errorList, $name, $data, $request); } } @@ -206,12 +209,57 @@ private function checkLength(array $errorList, string $name, array $data, Reques return $errorList; } + public function checkPattern(array $errorList, string $name, array $data, Request $request): array + { + $value = $request->query->get($name); + if (empty($value) && '0' !== $value || !\is_string($value)) { + return $errorList; + } + + $pattern = $data['swagger']['pattern'] ?? null; + + if (null !== $pattern && !preg_match($pattern, $value)) { + $errorList[] = sprintf('Query parameter "%s" must match pattern %s', $name, $pattern); + } + + return $errorList; + } + + public function checkEnum(array $errorList, string $name, array $data, Request $request): array + { + $value = $request->query->get($name); + if (empty($value) && '0' !== $value || !\is_string($value)) { + return $errorList; + } + + $enum = $data['swagger']['enum'] ?? null; + + if (null !== $enum && !\in_array($value, $enum, true)) { + $errorList[] = sprintf('Query parameter "%s" must be one of "%s"', $name, implode(', ', $enum)); + } + + return $errorList; + } + + public function checkMultipleOf(array $errorList, string $name, array $data, Request $request): array + { + $value = $request->query->get($name); + if (empty($value) && '0' !== $value || !\is_string($value)) { + return $errorList; + } + + $multipleOf = $data['swagger']['multipleOf'] ?? null; + + if (null !== $multipleOf && 0 !== ($value % $multipleOf)) { + $errorList[] = sprintf('Query parameter "%s" must multiple of %s', $name, $multipleOf); + } + + return $errorList; + } + // TODO grouper les filtres required dans une classe // avoir deux entités, une required, une pour le reste - // pattern string See https://tools.ietf.org/html/draft-fge-json-schema-validation-00#section-5.2.3. // maxItems integer See https://tools.ietf.org/html/draft-fge-json-schema-validation-00#section-5.3.2. // minItems integer See https://tools.ietf.org/html/draft-fge-json-schema-validation-00#section-5.3.3. // uniqueItems boolean See https://tools.ietf.org/html/draft-fge-json-schema-validation-00#section-5.3.4. - // enum [*] See https://tools.ietf.org/html/draft-fge-json-schema-validation-00#section-5.5.1. - // multipleOf } diff --git a/tests/Fixtures/TestBundle/Entity/FilterValidator.php b/tests/Fixtures/TestBundle/Entity/FilterValidator.php index e1a76feb51d..359a3042911 100644 --- a/tests/Fixtures/TestBundle/Entity/FilterValidator.php +++ b/tests/Fixtures/TestBundle/Entity/FilterValidator.php @@ -16,7 +16,10 @@ use ApiPlatform\Core\Annotation\ApiProperty; use ApiPlatform\Core\Annotation\ApiResource; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter\BoundsFilter; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter\EnumFilter; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter\LengthFilter; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter\MultipleOfFilter; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter\PatternFilter; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter\RequiredAllowEmptyFilter; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter\RequiredFilter; use Doctrine\ORM\Mapping as ORM; @@ -29,7 +32,10 @@ * @ApiResource(attributes={ * "filters"={ * BoundsFilter::class, + * EnumFilter::class, * LengthFilter::class, + * MultipleOfFilter::class, + * PatternFilter::class, * RequiredFilter::class, * RequiredAllowEmptyFilter::class * } diff --git a/tests/Fixtures/TestBundle/Filter/EnumFilter.php b/tests/Fixtures/TestBundle/Filter/EnumFilter.php new file mode 100644 index 00000000000..a2fe49b2598 --- /dev/null +++ b/tests/Fixtures/TestBundle/Filter/EnumFilter.php @@ -0,0 +1,40 @@ + + * + * 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\Tests\Fixtures\TestBundle\Filter; + +use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\AbstractFilter; +use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use Doctrine\ORM\QueryBuilder; + +class EnumFilter extends AbstractFilter +{ + protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null) + { + } + + // This function is only used to hook in documentation generators (supported by Swagger and Hydra) + public function getDescription(string $resourceClass): array + { + return [ + 'enum' => [ + 'property' => 'enum', + 'type' => 'string', + 'required' => false, + 'swagger' => [ + 'enum' => ['in-enum', 'mune-ni'], + ], + ], + ]; + } +} diff --git a/tests/Fixtures/TestBundle/Filter/MultipleOfFilter.php b/tests/Fixtures/TestBundle/Filter/MultipleOfFilter.php new file mode 100644 index 00000000000..6f0703bec8c --- /dev/null +++ b/tests/Fixtures/TestBundle/Filter/MultipleOfFilter.php @@ -0,0 +1,40 @@ + + * + * 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\Tests\Fixtures\TestBundle\Filter; + +use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\AbstractFilter; +use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use Doctrine\ORM\QueryBuilder; + +class MultipleOfFilter extends AbstractFilter +{ + protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null) + { + } + + // This function is only used to hook in documentation generators (supported by Swagger and Hydra) + public function getDescription(string $resourceClass): array + { + return [ + 'multiple-of' => [ + 'property' => 'multiple-of', + 'type' => 'number', + 'required' => false, + 'swagger' => [ + 'multipleOf' => 2, + ], + ], + ]; + } +} diff --git a/tests/Fixtures/TestBundle/Filter/PatternFilter.php b/tests/Fixtures/TestBundle/Filter/PatternFilter.php new file mode 100644 index 00000000000..ccb9f56e731 --- /dev/null +++ b/tests/Fixtures/TestBundle/Filter/PatternFilter.php @@ -0,0 +1,40 @@ + + * + * 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\Tests\Fixtures\TestBundle\Filter; + +use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\AbstractFilter; +use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use Doctrine\ORM\QueryBuilder; + +class PatternFilter extends AbstractFilter +{ + protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null) + { + } + + // This function is only used to hook in documentation generators (supported by Swagger and Hydra) + public function getDescription(string $resourceClass): array + { + return [ + 'pattern' => [ + 'property' => 'pattern', + 'type' => 'string', + 'required' => false, + 'swagger' => [ + 'pattern' => '/^(pattern|nrettap)$/', + ], + ], + ]; + } +} diff --git a/tests/Fixtures/app/config/config_common.yml b/tests/Fixtures/app/config/config_common.yml index ee301765248..f6556d6662f 100644 --- a/tests/Fixtures/app/config/config_common.yml +++ b/tests/Fixtures/app/config/config_common.yml @@ -153,10 +153,22 @@ services: arguments: [ '@doctrine' ] tags: [ 'api_platform.filter' ] + ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter\PatternFilter: + arguments: [ '@doctrine' ] + tags: [ 'api_platform.filter' ] + ApiPlatform\Core\Tests\Fixtures\TestBundle\Controller\: resource: '../../TestBundle/Controller' tags: ['controller.service_arguments'] + ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter\EnumFilter: + arguments: [ '@doctrine' ] + tags: [ 'api_platform.filter' ] + + ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter\MultipleOfFilter: + arguments: [ '@doctrine' ] + tags: [ 'api_platform.filter' ] + app.config_dummy_resource.action: class: 'ApiPlatform\Core\Tests\Fixtures\TestBundle\Action\ConfigCustom' arguments: ['@api_platform.item_data_provider'] From 950c172d701c629af0a63d41d0653897aad90b1f Mon Sep 17 00:00:00 2001 From: Julien Deniau Date: Wed, 21 Feb 2018 17:12:43 +0100 Subject: [PATCH 031/160] move validators in separate files --- src/Filter/QueryParameterValidateListener.php | 204 ++---------------- src/Filter/Validator/Bounds.php | 50 +++++ src/Filter/Validator/Enum.php | 37 ++++ src/Filter/Validator/Length.php | 42 ++++ src/Filter/Validator/MultipleOf.php | 37 ++++ src/Filter/Validator/Pattern.php | 37 ++++ src/Filter/Validator/Required.php | 101 +++++++++ src/Filter/Validator/ValidatorInterface.php | 21 ++ 8 files changed, 339 insertions(+), 190 deletions(-) create mode 100644 src/Filter/Validator/Bounds.php create mode 100644 src/Filter/Validator/Enum.php create mode 100644 src/Filter/Validator/Length.php create mode 100644 src/Filter/Validator/MultipleOf.php create mode 100644 src/Filter/Validator/Pattern.php create mode 100644 src/Filter/Validator/Required.php create mode 100644 src/Filter/Validator/ValidatorInterface.php diff --git a/src/Filter/QueryParameterValidateListener.php b/src/Filter/QueryParameterValidateListener.php index c38e520ae16..2315ab60123 100644 --- a/src/Filter/QueryParameterValidateListener.php +++ b/src/Filter/QueryParameterValidateListener.php @@ -18,7 +18,6 @@ use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use ApiPlatform\Core\Util\RequestAttributesExtractor; use Psr\Container\ContainerInterface; -use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Event\RequestEvent; /** @@ -32,10 +31,21 @@ final class QueryParameterValidateListener private $resourceMetadataFactory; + private $validators; + public function __construct(ResourceMetadataFactoryInterface $resourceMetadataFactory, ContainerInterface $filterLocator) { $this->resourceMetadataFactory = $resourceMetadataFactory; $this->setFilterLocator($filterLocator); + + $this->validators = [ + new Validator\Required(), + new Validator\Bounds(), + new Validator\Length(), + new Validator\Pattern(), + new Validator\Enum(), + new Validator\MultipleOf(), + ]; } public function onKernelRequest(RequestEvent $event): void @@ -60,12 +70,9 @@ public function onKernelRequest(RequestEvent $event): void } foreach ($filter->getDescription($attributes['resource_class']) as $name => $data) { - $errorList = $this->checkRequired($errorList, $name, $data, $request); - $errorList = $this->checkBounds($errorList, $name, $data, $request); - $errorList = $this->checkLength($errorList, $name, $data, $request); - $errorList = $this->checkPattern($errorList, $name, $data, $request); - $errorList = $this->checkEnum($errorList, $name, $data, $request); - $errorList = $this->checkMultipleOf($errorList, $name, $data, $request); + foreach ($this->validators as $validator) { + $errorList = array_merge($errorList, $validator->validate($name, $data, $request)); + } } } @@ -74,189 +81,6 @@ public function onKernelRequest(RequestEvent $event): void } } - private function checkRequired(array $errorList, string $name, array $data, Request $request): array - { - // filter is not required, the `checkRequired` method can not break - if (!($data['required'] ?? false)) { - return $errorList; - } - - // if query param is not given, then break - if (!$this->requestHasQueryParameter($request, $name)) { - $errorList[] = sprintf('Query parameter "%s" is required', $name); - - return $errorList; - } - - // if query param is empty and the configuration does not allow it - if (!($data['swagger']['allowEmptyValue'] ?? false) && empty($this->requestGetQueryParameter($request, $name))) { - $errorList[] = sprintf('Query parameter "%s" does not allow empty value', $name); - } - - return $errorList; - } - - /** - * Test if request has required parameter. - */ - private function requestHasQueryParameter(Request $request, string $name): bool - { - $matches = []; - parse_str($name, $matches); - if (!$matches) { - return false; - } - - $rootName = (string) (array_keys($matches)[0] ?? null); - if (!$rootName) { - return false; - } - - if (\is_array($matches[$rootName])) { - $keyName = array_keys($matches[$rootName])[0]; - - $queryParameter = $request->query->get($rootName); - - return \is_array($queryParameter) && isset($queryParameter[$keyName]); - } - - return $request->query->has($rootName); - } - - /** - * Test if required filter is valid. It validates array notation too like "required[bar]". - */ - private function requestGetQueryParameter(Request $request, string $name) - { - $matches = []; - parse_str($name, $matches); - if (!$matches) { - return null; - } - - $rootName = array_keys($matches)[0] ?? ''; - if (!$rootName) { - return null; - } - - if (\is_array($matches[$rootName])) { - $keyName = array_keys($matches[$rootName])[0]; - - $queryParameter = $request->query->get($rootName); - - if (\is_array($queryParameter) && isset($queryParameter[$keyName])) { - return $queryParameter[$keyName]; - } - - return null; - } - - return $request->query->get($rootName); - } - - private function checkBounds(array $errorList, string $name, array $data, Request $request): array - { - $value = $request->query->get($name); - if (empty($value) && '0' !== $value) { - return $errorList; - } - - $maximum = $data['swagger']['maximum'] ?? null; - $minimum = $data['swagger']['minimum'] ?? null; - - if (null !== $maximum) { - if (($data['swagger']['exclusiveMaximum'] ?? false) && $value >= $maximum) { - $errorList[] = sprintf('Query parameter "%s" must be less than %s', $name, $maximum); - } elseif ($value > $maximum) { - $errorList[] = sprintf('Query parameter "%s" must be less than or equal to %s', $name, $maximum); - } - } - - if (null !== $minimum) { - if (($data['swagger']['exclusiveMinimum'] ?? false) && $value <= $minimum) { - $errorList[] = sprintf('Query parameter "%s" must be greater than %s', $name, $minimum); - } elseif ($value < $minimum) { - $errorList[] = sprintf('Query parameter "%s" must be greater than or equal to %s', $name, $minimum); - } - } - - return $errorList; - } - - private function checkLength(array $errorList, string $name, array $data, Request $request): array - { - $maxLength = $data['swagger']['maxLength'] ?? null; - $minLength = $data['swagger']['minLength'] ?? null; - - $value = $request->query->get($name); - if (empty($value) && '0' !== $value || !\is_string($value)) { - return $errorList; - } - - // if (!is_string($value)) { - // $errorList[] = sprintf('Query parameter "%s" must be less than or equal to %s', $name, $maximum); - // return $errorList; - // } - - if (null !== $maxLength && mb_strlen($value) > $maxLength) { - $errorList[] = sprintf('Query parameter "%s" length must be lower than or equal to %s', $name, $maxLength); - } - - if (null !== $minLength && mb_strlen($value) < $minLength) { - $errorList[] = sprintf('Query parameter "%s" length must be greater than or equal to %s', $name, $minLength); - } - - return $errorList; - } - - public function checkPattern(array $errorList, string $name, array $data, Request $request): array - { - $value = $request->query->get($name); - if (empty($value) && '0' !== $value || !\is_string($value)) { - return $errorList; - } - - $pattern = $data['swagger']['pattern'] ?? null; - - if (null !== $pattern && !preg_match($pattern, $value)) { - $errorList[] = sprintf('Query parameter "%s" must match pattern %s', $name, $pattern); - } - - return $errorList; - } - - public function checkEnum(array $errorList, string $name, array $data, Request $request): array - { - $value = $request->query->get($name); - if (empty($value) && '0' !== $value || !\is_string($value)) { - return $errorList; - } - - $enum = $data['swagger']['enum'] ?? null; - - if (null !== $enum && !\in_array($value, $enum, true)) { - $errorList[] = sprintf('Query parameter "%s" must be one of "%s"', $name, implode(', ', $enum)); - } - - return $errorList; - } - - public function checkMultipleOf(array $errorList, string $name, array $data, Request $request): array - { - $value = $request->query->get($name); - if (empty($value) && '0' !== $value || !\is_string($value)) { - return $errorList; - } - - $multipleOf = $data['swagger']['multipleOf'] ?? null; - - if (null !== $multipleOf && 0 !== ($value % $multipleOf)) { - $errorList[] = sprintf('Query parameter "%s" must multiple of %s', $name, $multipleOf); - } - - return $errorList; - } - // TODO grouper les filtres required dans une classe // avoir deux entités, une required, une pour le reste // maxItems integer See https://tools.ietf.org/html/draft-fge-json-schema-validation-00#section-5.3.2. diff --git a/src/Filter/Validator/Bounds.php b/src/Filter/Validator/Bounds.php new file mode 100644 index 00000000000..119d4c9dba6 --- /dev/null +++ b/src/Filter/Validator/Bounds.php @@ -0,0 +1,50 @@ + + * + * 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\Filter\Validator; + +use Symfony\Component\HttpFoundation\Request; + +class Bounds implements ValidatorInterface +{ + public function validate(string $name, array $filterDescription, Request $request): array + { + $value = $request->query->get($name); + if (empty($value) && '0' !== $value) { + return []; + } + + $maximum = $filterDescription['swagger']['maximum'] ?? null; + $minimum = $filterDescription['swagger']['minimum'] ?? null; + + $errorList = []; + + if (null !== $maximum) { + if (($filterDescription['swagger']['exclusiveMaximum'] ?? false) && $value >= $maximum) { + $errorList[] = sprintf('Query parameter "%s" must be less than %s', $name, $maximum); + } elseif ($value > $maximum) { + $errorList[] = sprintf('Query parameter "%s" must be less than or equal to %s', $name, $maximum); + } + } + + if (null !== $minimum) { + if (($filterDescription['swagger']['exclusiveMinimum'] ?? false) && $value <= $minimum) { + $errorList[] = sprintf('Query parameter "%s" must be greater than %s', $name, $minimum); + } elseif ($value < $minimum) { + $errorList[] = sprintf('Query parameter "%s" must be greater than or equal to %s', $name, $minimum); + } + } + + return $errorList; + } +} diff --git a/src/Filter/Validator/Enum.php b/src/Filter/Validator/Enum.php new file mode 100644 index 00000000000..abc4b8b89df --- /dev/null +++ b/src/Filter/Validator/Enum.php @@ -0,0 +1,37 @@ + + * + * 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\Filter\Validator; + +use Symfony\Component\HttpFoundation\Request; + +class Enum implements ValidatorInterface +{ + public function validate(string $name, array $filterDescription, Request $request): array + { + $value = $request->query->get($name); + if (empty($value) && '0' !== $value || !\is_string($value)) { + return []; + } + + $enum = $filterDescription['swagger']['enum'] ?? null; + + if (null !== $enum && !\in_array($value, $enum, true)) { + return [ + sprintf('Query parameter "%s" must be one of "%s"', $name, implode(', ', $enum)), + ]; + } + + return []; + } +} diff --git a/src/Filter/Validator/Length.php b/src/Filter/Validator/Length.php new file mode 100644 index 00000000000..a64d370cec1 --- /dev/null +++ b/src/Filter/Validator/Length.php @@ -0,0 +1,42 @@ + + * + * 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\Filter\Validator; + +use Symfony\Component\HttpFoundation\Request; + +class Length implements ValidatorInterface +{ + public function validate(string $name, array $filterDescription, Request $request): array + { + $maxLength = $filterDescription['swagger']['maxLength'] ?? null; + $minLength = $filterDescription['swagger']['minLength'] ?? null; + + $value = $request->query->get($name); + if (empty($value) && '0' !== $value || !\is_string($value)) { + return []; + } + + $errorList = []; + + if (null !== $maxLength && mb_strlen($value) > $maxLength) { + $errorList[] = sprintf('Query parameter "%s" length must be lower than or equal to %s', $name, $maxLength); + } + + if (null !== $minLength && mb_strlen($value) < $minLength) { + $errorList[] = sprintf('Query parameter "%s" length must be greater than or equal to %s', $name, $minLength); + } + + return $errorList; + } +} diff --git a/src/Filter/Validator/MultipleOf.php b/src/Filter/Validator/MultipleOf.php new file mode 100644 index 00000000000..a4f7ee90fa1 --- /dev/null +++ b/src/Filter/Validator/MultipleOf.php @@ -0,0 +1,37 @@ + + * + * 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\Filter\Validator; + +use Symfony\Component\HttpFoundation\Request; + +class MultipleOf implements ValidatorInterface +{ + public function validate(string $name, array $filterDescription, Request $request): array + { + $value = $request->query->get($name); + if (empty($value) && '0' !== $value || !\is_string($value)) { + return []; + } + + $multipleOf = $filterDescription['swagger']['multipleOf'] ?? null; + + if (null !== $multipleOf && 0 !== ($value % $multipleOf)) { + return [ + sprintf('Query parameter "%s" must multiple of %s', $name, $multipleOf), + ]; + } + + return []; + } +} diff --git a/src/Filter/Validator/Pattern.php b/src/Filter/Validator/Pattern.php new file mode 100644 index 00000000000..c6c46667416 --- /dev/null +++ b/src/Filter/Validator/Pattern.php @@ -0,0 +1,37 @@ + + * + * 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\Filter\Validator; + +use Symfony\Component\HttpFoundation\Request; + +class Pattern implements ValidatorInterface +{ + public function validate(string $name, array $filterDescription, Request $request): array + { + $value = $request->query->get($name); + if (empty($value) && '0' !== $value || !\is_string($value)) { + return []; + } + + $pattern = $filterDescription['swagger']['pattern'] ?? null; + + if (null !== $pattern && !preg_match($pattern, $value)) { + return [ + sprintf('Query parameter "%s" must match pattern %s', $name, $pattern), + ]; + } + + return []; + } +} diff --git a/src/Filter/Validator/Required.php b/src/Filter/Validator/Required.php new file mode 100644 index 00000000000..ff6332bb590 --- /dev/null +++ b/src/Filter/Validator/Required.php @@ -0,0 +1,101 @@ + + * + * 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\Filter\Validator; + +use Symfony\Component\HttpFoundation\Request; + +class Required implements ValidatorInterface +{ + public function validate(string $name, array $filterDescription, Request $request): array + { + // filter is not required, the `checkRequired` method can not break + if (!($filterDescription['required'] ?? false)) { + return []; + } + + // if query param is not given, then break + if (!$this->requestHasQueryParameter($request, $name)) { + return [ + sprintf('Query parameter "%s" is required', $name), + ]; + } + + // if query param is empty and the configuration does not allow it + if (!($filterDescription['swagger']['allowEmptyValue'] ?? false) && empty($this->requestGetQueryParameter($request, $name))) { + return [ + sprintf('Query parameter "%s" does not allow empty value', $name), + ]; + } + + return []; + } + + /** + * Test if request has required parameter. + */ + private function requestHasQueryParameter(Request $request, string $name): bool + { + $matches = []; + parse_str($name, $matches); + if (!$matches) { + return false; + } + + $rootName = array_keys($matches)[0] ?? ''; + if (!$rootName) { + return false; + } + + if (\is_array($matches[$rootName])) { + $keyName = array_keys($matches[$rootName])[0]; + + $queryParameter = $request->query->get($rootName); + + return \is_array($queryParameter) && isset($queryParameter[$keyName]); + } + + return $request->query->has($rootName); + } + + /** + * Test if required filter is valid. It validates array notation too like "required[bar]". + */ + private function requestGetQueryParameter(Request $request, string $name) + { + $matches = []; + parse_str($name, $matches); + if (empty($matches)) { + return null; + } + + $rootName = array_keys($matches)[0] ?? ''; + if (!$rootName) { + return null; + } + + if (\is_array($matches[$rootName])) { + $keyName = array_keys($matches[$rootName])[0]; + + $queryParameter = $request->query->get($rootName); + + if (\is_array($queryParameter) && isset($queryParameter[$keyName])) { + return $queryParameter[$keyName]; + } + + return null; + } + + return $request->query->get($rootName); + } +} diff --git a/src/Filter/Validator/ValidatorInterface.php b/src/Filter/Validator/ValidatorInterface.php new file mode 100644 index 00000000000..33740725059 --- /dev/null +++ b/src/Filter/Validator/ValidatorInterface.php @@ -0,0 +1,21 @@ + + * + * 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\Filter\Validator; + +use Symfony\Component\HttpFoundation\Request; + +interface ValidatorInterface +{ + public function validate(string $name, array $filterDescription, Request $request): array; +} From 0da5c75c22b3b9c1b9482e33f24dae85de6a2be9 Mon Sep 17 00:00:00 2001 From: Julien Deniau Date: Thu, 22 Feb 2018 08:20:41 +0100 Subject: [PATCH 032/160] validate maxItems, minItems & uniqueItems filter --- features/filter/filter_validation.feature | 50 ++++++++++- src/Filter/QueryParameterValidateListener.php | 13 +-- src/Filter/Validator/ArrayItems.php | 82 ++++++++++++++++++ .../TestBundle/Entity/FilterValidator.php | 2 + .../TestBundle/Filter/ArrayItemsFilter.php | 83 +++++++++++++++++++ tests/Fixtures/app/config/config_common.yml | 4 + 6 files changed, 224 insertions(+), 10 deletions(-) create mode 100644 src/Filter/Validator/ArrayItems.php create mode 100644 tests/Fixtures/TestBundle/Filter/ArrayItemsFilter.php diff --git a/features/filter/filter_validation.feature b/features/filter/filter_validation.feature index 77f1d7ff87d..23b63bcff09 100644 --- a/features/filter/filter_validation.feature +++ b/features/filter/filter_validation.feature @@ -109,7 +109,6 @@ Feature: Validate filters based upon filter description Then the response status code should be 400 And the JSON node "detail" should be equal to 'Query parameter "enum" must be one of "in-enum, mune-ni"' - @dropSchema Scenario: Test filter multipleOf When I am on "/filter_validators?required=foo&required-allow-empty&multiple-of=4" Then the response status code should be 200 @@ -117,3 +116,52 @@ Feature: Validate filters based upon filter description When I am on "/filter_validators?required=foo&required-allow-empty&multiple-of=3" Then the response status code should be 400 And the JSON node "detail" should be equal to 'Query parameter "multiple-of" must multiple of 2' + + Scenario: Test filter array items csv format minItems + When I am on "/filter_validators?required=foo&required-allow-empty&csv-min-2=a,b" + Then the response status code should be 200 + + When I am on "/filter_validators?required=foo&required-allow-empty&csv-min-2=a" + Then the response status code should be 400 + And the JSON node "detail" should be equal to 'Query parameter "csv-min-2" must contain more than 2 values' + + Scenario: Test filter array items csv format maxItems + When I am on "/filter_validators?required=foo&required-allow-empty&csv-max-3=a,b,c" + Then the response status code should be 200 + + When I am on "/filter_validators?required=foo&required-allow-empty&csv-max-3=a,b,c,d" + Then the response status code should be 400 + And the JSON node "detail" should be equal to 'Query parameter "csv-max-3" must contain less than 3 values' + + Scenario: Test filter array items tsv format minItems + When I am on "/filter_validators?required=foo&required-allow-empty&tsv-min-2=a\tb" + Then the response status code should be 200 + + When I am on "/filter_validators?required=foo&required-allow-empty&tsv-min-2=a,b" + Then the response status code should be 400 + And the JSON node "detail" should be equal to 'Query parameter "tsv-min-2" must contain more than 2 values' + + Scenario: Test filter array items pipes format minItems + When I am on "/filter_validators?required=foo&required-allow-empty&pipes-min-2=a|b" + Then the response status code should be 200 + + When I am on "/filter_validators?required=foo&required-allow-empty&pipes-min-2=a,b" + Then the response status code should be 400 + And the JSON node "detail" should be equal to 'Query parameter "pipes-min-2" must contain more than 2 values' + + Scenario: Test filter array items ssv format minItems + When I am on "/filter_validators?required=foo&required-allow-empty&ssv-min-2=a b" + Then the response status code should be 200 + + When I am on "/filter_validators?required=foo&required-allow-empty&ssv-min-2=a,b" + Then the response status code should be 400 + And the JSON node "detail" should be equal to 'Query parameter "ssv-min-2" must contain more than 2 values' + + @dropSchema + Scenario: Test filter array items unique items + When I am on "/filter_validators?required=foo&required-allow-empty&csv-uniques=a,b" + Then the response status code should be 200 + + When I am on "/filter_validators?required=foo&required-allow-empty&csv-uniques=a,a" + Then the response status code should be 400 + And the JSON node "detail" should be equal to 'Query parameter "csv-uniques" must contain unique values' diff --git a/src/Filter/QueryParameterValidateListener.php b/src/Filter/QueryParameterValidateListener.php index 2315ab60123..1707c1bd362 100644 --- a/src/Filter/QueryParameterValidateListener.php +++ b/src/Filter/QueryParameterValidateListener.php @@ -39,12 +39,13 @@ public function __construct(ResourceMetadataFactoryInterface $resourceMetadataFa $this->setFilterLocator($filterLocator); $this->validators = [ - new Validator\Required(), + new Validator\ArrayItems(), new Validator\Bounds(), - new Validator\Length(), - new Validator\Pattern(), new Validator\Enum(), + new Validator\Length(), new Validator\MultipleOf(), + new Validator\Pattern(), + new Validator\Required(), ]; } @@ -80,10 +81,4 @@ public function onKernelRequest(RequestEvent $event): void throw new FilterValidationException($errorList); } } - - // TODO grouper les filtres required dans une classe - // avoir deux entités, une required, une pour le reste - // maxItems integer See https://tools.ietf.org/html/draft-fge-json-schema-validation-00#section-5.3.2. - // minItems integer See https://tools.ietf.org/html/draft-fge-json-schema-validation-00#section-5.3.3. - // uniqueItems boolean See https://tools.ietf.org/html/draft-fge-json-schema-validation-00#section-5.3.4. } diff --git a/src/Filter/Validator/ArrayItems.php b/src/Filter/Validator/ArrayItems.php new file mode 100644 index 00000000000..fd903925cb6 --- /dev/null +++ b/src/Filter/Validator/ArrayItems.php @@ -0,0 +1,82 @@ + + * + * 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\Filter\Validator; + +use Symfony\Component\HttpFoundation\Request; + +class ArrayItems implements ValidatorInterface +{ + public function validate(string $name, array $filterDescription, Request $request): array + { + if (!$request->query->has($name)) { + return []; + } + + $maxItems = $filterDescription['swagger']['maxItems'] ?? null; + $minItems = $filterDescription['swagger']['minItems'] ?? null; + $uniqueItems = $filterDescription['swagger']['uniqueItems'] ?? false; + + $errorList = []; + + $value = $this->getValue($name, $filterDescription, $request); + $nbItems = \count($value); + + if (null !== $maxItems && $nbItems > $maxItems) { + $errorList[] = sprintf('Query parameter "%s" must contain less than %d values', $name, $maxItems); + } + + if (null !== $minItems && $nbItems < $minItems) { + $errorList[] = sprintf('Query parameter "%s" must contain more than %d values', $name, $minItems); + } + + if (true === $uniqueItems && $nbItems > \count(array_unique($value))) { + $errorList[] = sprintf('Query parameter "%s" must contain unique values', $name); + } + + return $errorList; + } + + private function getValue(string $name, array $filterDescription, Request $request): array + { + $value = $request->query->get($name); + + if (empty($value) && '0' !== $value) { + return []; + } + + if (\is_array($value)) { + return $value; + } + + $collectionFormat = $filterDescription['swagger']['collectionFormat'] ?? 'csv'; + + return explode(self::getSeparator($collectionFormat), $value); + } + + private static function getSeparator(string $collectionFormat): string + { + switch ($collectionFormat) { + case 'csv': + return ','; + case 'ssv': + return ' '; + case 'tsv': + return '\t'; + case 'pipes': + return '|'; + default: + throw new \InvalidArgumentException(sprintf('Unkwown collection format %s', $collectionFormat)); + } + } +} diff --git a/tests/Fixtures/TestBundle/Entity/FilterValidator.php b/tests/Fixtures/TestBundle/Entity/FilterValidator.php index 359a3042911..eaa62b26345 100644 --- a/tests/Fixtures/TestBundle/Entity/FilterValidator.php +++ b/tests/Fixtures/TestBundle/Entity/FilterValidator.php @@ -15,6 +15,7 @@ use ApiPlatform\Core\Annotation\ApiProperty; use ApiPlatform\Core\Annotation\ApiResource; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter\ArrayItemsFilter; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter\BoundsFilter; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter\EnumFilter; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter\LengthFilter; @@ -31,6 +32,7 @@ * * @ApiResource(attributes={ * "filters"={ + * ArrayItemsFilter::class, * BoundsFilter::class, * EnumFilter::class, * LengthFilter::class, diff --git a/tests/Fixtures/TestBundle/Filter/ArrayItemsFilter.php b/tests/Fixtures/TestBundle/Filter/ArrayItemsFilter.php new file mode 100644 index 00000000000..7feb9b3409f --- /dev/null +++ b/tests/Fixtures/TestBundle/Filter/ArrayItemsFilter.php @@ -0,0 +1,83 @@ + + * + * 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\Tests\Fixtures\TestBundle\Filter; + +use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\AbstractFilter; +use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use Doctrine\ORM\QueryBuilder; + +class ArrayItemsFilter extends AbstractFilter +{ + protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null) + { + } + + // This function is only used to hook in documentation generators (supported by Swagger and Hydra) + public function getDescription(string $resourceClass): array + { + return [ + 'csv-min-2' => [ + 'property' => 'csv-min-2', + 'type' => 'array', + 'required' => false, + 'swagger' => [ + 'minItems' => 2, + ], + ], + 'csv-max-3' => [ + 'property' => 'csv-max-3', + 'type' => 'array', + 'required' => false, + 'swagger' => [ + 'maxItems' => 3, + ], + ], + 'ssv-min-2' => [ + 'property' => 'ssv-min-2', + 'type' => 'array', + 'required' => false, + 'swagger' => [ + 'collectionFormat' => 'ssv', + 'minItems' => 2, + ], + ], + 'tsv-min-2' => [ + 'property' => 'tsv-min-2', + 'type' => 'array', + 'required' => false, + 'swagger' => [ + 'collectionFormat' => 'tsv', + 'minItems' => 2, + ], + ], + 'pipes-min-2' => [ + 'property' => 'pipes-min-2', + 'type' => 'array', + 'required' => false, + 'swagger' => [ + 'collectionFormat' => 'pipes', + 'minItems' => 2, + ], + ], + 'csv-uniques' => [ + 'property' => 'csv-uniques', + 'type' => 'array', + 'required' => false, + 'swagger' => [ + 'uniqueItems' => true, + ], + ], + ]; + } +} diff --git a/tests/Fixtures/app/config/config_common.yml b/tests/Fixtures/app/config/config_common.yml index f6556d6662f..0fe02d4b37d 100644 --- a/tests/Fixtures/app/config/config_common.yml +++ b/tests/Fixtures/app/config/config_common.yml @@ -169,6 +169,10 @@ services: arguments: [ '@doctrine' ] tags: [ 'api_platform.filter' ] + ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter\ArrayItemsFilter: + arguments: [ '@doctrine' ] + tags: [ 'api_platform.filter' ] + app.config_dummy_resource.action: class: 'ApiPlatform\Core\Tests\Fixtures\TestBundle\Action\ConfigCustom' arguments: ['@api_platform.item_data_provider'] From 3a20868abcb68b0d075dfb1ae8eb13aae2c55b97 Mon Sep 17 00:00:00 2001 From: Julien Deniau Date: Sat, 16 Jun 2018 16:11:17 +0200 Subject: [PATCH 033/160] split query parameter listener into a validator + a listener (need to split tests as well) --- .../Bundle/Resources/config/validator.xml | 8 ++- .../QueryParameterValidateListener.php | 55 +++++++++++++++++++ ...stener.php => QueryParameterValidator.php} | 28 ++-------- .../ApiPlatformExtensionTest.php | 1 + .../QueryParameterValidateListenerTest.php | 2 +- 5 files changed, 68 insertions(+), 26 deletions(-) create mode 100644 src/EventListener/QueryParameterValidateListener.php rename src/Filter/{QueryParameterValidateListener.php => QueryParameterValidator.php} (55%) rename tests/{Filter => EventListener}/QueryParameterValidateListenerTest.php (98%) diff --git a/src/Bridge/Symfony/Bundle/Resources/config/validator.xml b/src/Bridge/Symfony/Bundle/Resources/config/validator.xml index d34dcd8cfe6..2526c93cfc6 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/validator.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/validator.xml @@ -23,9 +23,13 @@ - - + + + + + + diff --git a/src/EventListener/QueryParameterValidateListener.php b/src/EventListener/QueryParameterValidateListener.php new file mode 100644 index 00000000000..14990387485 --- /dev/null +++ b/src/EventListener/QueryParameterValidateListener.php @@ -0,0 +1,55 @@ + + * + * 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\EventListener; + +use ApiPlatform\Core\Filter\QueryParameterValidator; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\Util\RequestAttributesExtractor; +use Symfony\Component\HttpKernel\Event\RequestEvent; + +/** + * Validates query parameters depending on filter description. + * + * @author Julien Deniau + */ +final class QueryParameterValidateListener +{ + private $resourceMetadataFactory; + + private $queryParameterValidator; + + public function __construct(ResourceMetadataFactoryInterface $resourceMetadataFactory, QueryParameterValidator $queryParameterValidator) + { + $this->resourceMetadataFactory = $resourceMetadataFactory; + $this->queryParameterValidator = $queryParameterValidator; + } + + public function onKernelRequest(RequestEvent $event) + { + $request = $event->getRequest(); + if ( + !$request->isMethodSafe() + || !($attributes = RequestAttributesExtractor::extractAttributes($request)) + || !isset($attributes['collection_operation_name']) + || 'get' !== ($operationName = $attributes['collection_operation_name']) + ) { + return; + } + + $resourceMetadata = $this->resourceMetadataFactory->create($attributes['resource_class']); + $resourceFilters = $resourceMetadata->getCollectionOperationAttribute($operationName, 'filters', [], true); + + $this->queryParameterValidator->validateFilters($attributes['resource_class'], $resourceFilters, $request); + } +} diff --git a/src/Filter/QueryParameterValidateListener.php b/src/Filter/QueryParameterValidator.php similarity index 55% rename from src/Filter/QueryParameterValidateListener.php rename to src/Filter/QueryParameterValidator.php index 1707c1bd362..62f6f5d0e4a 100644 --- a/src/Filter/QueryParameterValidateListener.php +++ b/src/Filter/QueryParameterValidator.php @@ -15,27 +15,22 @@ use ApiPlatform\Core\Api\FilterLocatorTrait; use ApiPlatform\Core\Exception\FilterValidationException; -use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; -use ApiPlatform\Core\Util\RequestAttributesExtractor; use Psr\Container\ContainerInterface; -use Symfony\Component\HttpKernel\Event\RequestEvent; +use Symfony\Component\HttpFoundation\Request; /** * Validates query parameters depending on filter description. * * @author Julien Deniau */ -final class QueryParameterValidateListener +class QueryParameterValidator { use FilterLocatorTrait; - private $resourceMetadataFactory; - private $validators; - public function __construct(ResourceMetadataFactoryInterface $resourceMetadataFactory, ContainerInterface $filterLocator) + public function __construct(ContainerInterface $filterLocator) { - $this->resourceMetadataFactory = $resourceMetadataFactory; $this->setFilterLocator($filterLocator); $this->validators = [ @@ -49,28 +44,15 @@ public function __construct(ResourceMetadataFactoryInterface $resourceMetadataFa ]; } - public function onKernelRequest(RequestEvent $event): void + public function validateFilters(string $resourceClass, array $resourceFilters, Request $request) { - $request = $event->getRequest(); - if ( - !$request->isMethodSafe() - || !($attributes = RequestAttributesExtractor::extractAttributes($request)) - || !isset($attributes['collection_operation_name']) - || 'get' !== ($operationName = $attributes['collection_operation_name']) - ) { - return; - } - - $resourceMetadata = $this->resourceMetadataFactory->create($attributes['resource_class']); - $resourceFilters = $resourceMetadata->getCollectionOperationAttribute($operationName, 'filters', [], true); - $errorList = []; foreach ($resourceFilters as $filterId) { if (!$filter = $this->getFilter($filterId)) { continue; } - foreach ($filter->getDescription($attributes['resource_class']) as $name => $data) { + foreach ($filter->getDescription($resourceClass) as $name => $data) { foreach ($this->validators as $validator) { $errorList = array_merge($errorList, $validator->validate($name, $data, $request)); } diff --git a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php index e9034147400..93c83f0429d 100644 --- a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php +++ b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php @@ -1236,6 +1236,7 @@ private function getBaseContainerBuilderProphecy(array $doctrineIntegrationsToLo 'api_platform.swagger.action.ui', 'api_platform.swagger.listener.ui', 'api_platform.validator', + 'api_platform.validator.query_parameter_validator', 'test.api_platform.client', ]; diff --git a/tests/Filter/QueryParameterValidateListenerTest.php b/tests/EventListener/QueryParameterValidateListenerTest.php similarity index 98% rename from tests/Filter/QueryParameterValidateListenerTest.php rename to tests/EventListener/QueryParameterValidateListenerTest.php index 47e6efacfb2..96fb106191e 100644 --- a/tests/Filter/QueryParameterValidateListenerTest.php +++ b/tests/EventListener/QueryParameterValidateListenerTest.php @@ -14,8 +14,8 @@ namespace ApiPlatform\Core\Tests\Filter; use ApiPlatform\Core\Api\FilterInterface; +use ApiPlatform\Core\EventListener\QueryParameterValidateListener; use ApiPlatform\Core\Exception\FilterValidationException; -use ApiPlatform\Core\Filter\QueryParameterValidateListener; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy; From 70083744326fcb316b38c8c57b409782b3588087 Mon Sep 17 00:00:00 2001 From: Julien Deniau Date: Thu, 21 Jun 2018 14:12:50 +0200 Subject: [PATCH 034/160] split tests in two --- .../QueryParameterValidateListenerTest.php | 52 ++----- tests/Filter/QueryParameterValidatorTest.php | 130 ++++++++++++++++++ 2 files changed, 141 insertions(+), 41 deletions(-) create mode 100644 tests/Filter/QueryParameterValidatorTest.php diff --git a/tests/EventListener/QueryParameterValidateListenerTest.php b/tests/EventListener/QueryParameterValidateListenerTest.php index 96fb106191e..0c1cc92f258 100644 --- a/tests/EventListener/QueryParameterValidateListenerTest.php +++ b/tests/EventListener/QueryParameterValidateListenerTest.php @@ -13,21 +13,20 @@ namespace ApiPlatform\Core\Tests\Filter; -use ApiPlatform\Core\Api\FilterInterface; use ApiPlatform\Core\EventListener\QueryParameterValidateListener; use ApiPlatform\Core\Exception\FilterValidationException; +use ApiPlatform\Core\Filter\QueryParameterValidator; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy; use PHPUnit\Framework\TestCase; -use Psr\Container\ContainerInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Event\RequestEvent; class QueryParameterValidateListenerTest extends TestCase { private $testedInstance; - private $filterLocatorProphecy; + private $queryParameterValidor; /** * unsafe method should not use filter validations. @@ -60,8 +59,7 @@ public function testOnKernelRequestWithWrongFilter() $eventProphecy = $this->prophesize(RequestEvent::class); $eventProphecy->getRequest()->willReturn($request)->shouldBeCalled(); - $this->filterLocatorProphecy->has('some_inexistent_filter')->shouldBeCalled(); - $this->filterLocatorProphecy->get('some_inexistent_filter')->shouldNotBeCalled(); + $this->queryParameterValidor->validateFilters(Dummy::class, ['some_inexistent_filter'], $request)->shouldBeCalled(); $this->assertNull( $this->testedInstance->onKernelRequest($eventProphecy->reveal()) @@ -81,24 +79,10 @@ public function testOnKernelRequestWithRequiredFilterNotSet() $eventProphecy = $this->prophesize(RequestEvent::class); $eventProphecy->getRequest()->willReturn($request)->shouldBeCalled(); - $this->filterLocatorProphecy - ->has('some_filter') + $this->queryParameterValidor + ->validateFilters(Dummy::class, ['some_filter'], $request) ->shouldBeCalled() - ->willReturn(true); - $filterProphecy = $this->prophesize(FilterInterface::class); - $filterProphecy - ->getDescription(Dummy::class) - ->shouldBeCalled() - ->willReturn([ - 'required' => [ - 'required' => true, - ], - ]); - $this->filterLocatorProphecy - ->get('some_filter') - ->shouldBeCalled() - ->willReturn($filterProphecy->reveal()); - + ->willThrow(new FilterValidationException(['Query parameter "required" is required'])); $this->expectException(FilterValidationException::class); $this->expectExceptionMessage('Query parameter "required" is required'); $this->testedInstance->onKernelRequest($eventProphecy->reveal()); @@ -121,23 +105,9 @@ public function testOnKernelRequestWithRequiredFilter() $eventProphecy = $this->prophesize(RequestEvent::class); $eventProphecy->getRequest()->willReturn($request)->shouldBeCalled(); - $this->filterLocatorProphecy - ->has('some_filter') - ->shouldBeCalled() - ->willReturn(true); - $filterProphecy = $this->prophesize(FilterInterface::class); - $filterProphecy - ->getDescription(Dummy::class) - ->shouldBeCalled() - ->willReturn([ - 'required' => [ - 'required' => true, - ], - ]); - $this->filterLocatorProphecy - ->get('some_filter') - ->shouldBeCalled() - ->willReturn($filterProphecy->reveal()); + $this->queryParameterValidor + ->validateFilters(Dummy::class, ['some_filter'], $request) + ->shouldBeCalled(); $this->assertNull( $this->testedInstance->onKernelRequest($eventProphecy->reveal()) @@ -156,11 +126,11 @@ private function setUpWithFilters(array $filters = []) ]) ); - $this->filterLocatorProphecy = $this->prophesize(ContainerInterface::class); + $this->queryParameterValidor = $this->prophesize(QueryParameterValidator::class); $this->testedInstance = new QueryParameterValidateListener( $resourceMetadataFactoryProphecy->reveal(), - $this->filterLocatorProphecy->reveal() + $this->queryParameterValidor->reveal() ); } } diff --git a/tests/Filter/QueryParameterValidatorTest.php b/tests/Filter/QueryParameterValidatorTest.php new file mode 100644 index 00000000000..f6c4169f267 --- /dev/null +++ b/tests/Filter/QueryParameterValidatorTest.php @@ -0,0 +1,130 @@ + + * + * 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\Test\Filter; + +use ApiPlatform\Core\Api\FilterInterface; +use ApiPlatform\Core\Exception\FilterValidationException; +use ApiPlatform\Core\Filter\QueryParameterValidator; +use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; +use Symfony\Component\HttpFoundation\Request; + +/** + * Class QueryParameterValidatorTest. + * + * @author Julien Deniau + */ +class QueryParameterValidatorTest extends TestCase +{ + private $testedInstance; + private $filterLocatorProphecy; + + /** + * {@inheritdoc} + */ + protected function setUp(): void + { + $this->filterLocatorProphecy = $this->prophesize(ContainerInterface::class); + + $this->testedInstance = new QueryParameterValidator( + $this->filterLocatorProphecy->reveal() + ); + } + + /** + * unsafe method should not use filter validations. + */ + public function testOnKernelRequestWithUnsafeMethod() + { + $request = new Request(); + + $this->assertNull( + $this->testedInstance->validateFilters(Dummy::class, [], $request) + ); + } + + /** + * If the tested filter is non-existant, then nothing should append. + */ + public function testOnKernelRequestWithWrongFilter() + { + $request = new Request(); + + $this->assertNull( + $this->testedInstance->validateFilters(Dummy::class, ['some_inexistent_filter'], $request) + ); + } + + /** + * if the required parameter is not set, throw an FilterValidationException. + */ + public function testOnKernelRequestWithRequiredFilterNotSet() + { + $request = new Request(); + + $filterProphecy = $this->prophesize(FilterInterface::class); + $filterProphecy + ->getDescription(Dummy::class) + ->shouldBeCalled() + ->willReturn([ + 'required' => [ + 'required' => true, + ], + ]); + $this->filterLocatorProphecy + ->has('some_filter') + ->shouldBeCalled() + ->willReturn(true); + $this->filterLocatorProphecy + ->get('some_filter') + ->shouldBeCalled() + ->willReturn($filterProphecy->reveal()); + + $this->expectException(FilterValidationException::class); + $this->expectExceptionMessage('Query parameter "required" is required'); + $this->testedInstance->validateFilters(Dummy::class, ['some_filter'], $request); + } + + /** + * if the required parameter is set, no exception should be throwned. + */ + public function testOnKernelRequestWithRequiredFilter() + { + $request = new Request( + ['required' => 'foo'] + ); + + $this->filterLocatorProphecy + ->has('some_filter') + ->shouldBeCalled() + ->willReturn(true); + $filterProphecy = $this->prophesize(FilterInterface::class); + $filterProphecy + ->getDescription(Dummy::class) + ->shouldBeCalled() + ->willReturn([ + 'required' => [ + 'required' => true, + ], + ]); + $this->filterLocatorProphecy + ->get('some_filter') + ->shouldBeCalled() + ->willReturn($filterProphecy->reveal()); + + $this->assertNull( + $this->testedInstance->validateFilters(Dummy::class, ['some_filter'], $request) + ); + } +} From 437d87e6562bb91b974f91a463221bec4b96c52e Mon Sep 17 00:00:00 2001 From: Julien Deniau Date: Thu, 21 Jun 2018 15:23:37 +0200 Subject: [PATCH 035/160] add unit tests for each Validators --- src/Filter/Validator/ArrayItems.php | 2 +- .../QueryParameterValidateListenerTest.php | 2 +- tests/Filter/QueryParameterValidatorTest.php | 3 +- tests/Filter/Validator/ArrayItemsTest.php | 199 ++++++++++++++++++ tests/Filter/Validator/BoundsTest.php | 180 ++++++++++++++++ tests/Filter/Validator/EnumTest.php | 77 +++++++ tests/Filter/Validator/LengthTest.php | 142 +++++++++++++ tests/Filter/Validator/MultipleOfTest.php | 77 +++++++ tests/Filter/Validator/PatternTest.php | 102 +++++++++ tests/Filter/Validator/RequiredTest.php | 105 +++++++++ 10 files changed, 886 insertions(+), 3 deletions(-) create mode 100644 tests/Filter/Validator/ArrayItemsTest.php create mode 100644 tests/Filter/Validator/BoundsTest.php create mode 100644 tests/Filter/Validator/EnumTest.php create mode 100644 tests/Filter/Validator/LengthTest.php create mode 100644 tests/Filter/Validator/MultipleOfTest.php create mode 100644 tests/Filter/Validator/PatternTest.php create mode 100644 tests/Filter/Validator/RequiredTest.php diff --git a/src/Filter/Validator/ArrayItems.php b/src/Filter/Validator/ArrayItems.php index fd903925cb6..42dfa298eed 100644 --- a/src/Filter/Validator/ArrayItems.php +++ b/src/Filter/Validator/ArrayItems.php @@ -76,7 +76,7 @@ private static function getSeparator(string $collectionFormat): string case 'pipes': return '|'; default: - throw new \InvalidArgumentException(sprintf('Unkwown collection format %s', $collectionFormat)); + throw new \InvalidArgumentException(sprintf('Unknown collection format %s', $collectionFormat)); } } } diff --git a/tests/EventListener/QueryParameterValidateListenerTest.php b/tests/EventListener/QueryParameterValidateListenerTest.php index 0c1cc92f258..2c52067f715 100644 --- a/tests/EventListener/QueryParameterValidateListenerTest.php +++ b/tests/EventListener/QueryParameterValidateListenerTest.php @@ -11,7 +11,7 @@ declare(strict_types=1); -namespace ApiPlatform\Core\Tests\Filter; +namespace ApiPlatform\Core\Tests\EventListener; use ApiPlatform\Core\EventListener\QueryParameterValidateListener; use ApiPlatform\Core\Exception\FilterValidationException; diff --git a/tests/Filter/QueryParameterValidatorTest.php b/tests/Filter/QueryParameterValidatorTest.php index f6c4169f267..c0f781407a2 100644 --- a/tests/Filter/QueryParameterValidatorTest.php +++ b/tests/Filter/QueryParameterValidatorTest.php @@ -11,11 +11,12 @@ declare(strict_types=1); -namespace ApiPlatform\Core\Test\Filter; +namespace ApiPlatform\Core\Tests\Filter; use ApiPlatform\Core\Api\FilterInterface; use ApiPlatform\Core\Exception\FilterValidationException; use ApiPlatform\Core\Filter\QueryParameterValidator; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy; use PHPUnit\Framework\TestCase; use Psr\Container\ContainerInterface; use Symfony\Component\HttpFoundation\Request; diff --git a/tests/Filter/Validator/ArrayItemsTest.php b/tests/Filter/Validator/ArrayItemsTest.php new file mode 100644 index 00000000000..6e4d2a3e9a4 --- /dev/null +++ b/tests/Filter/Validator/ArrayItemsTest.php @@ -0,0 +1,199 @@ + + * + * 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\Tests\Filter\Validator; + +use ApiPlatform\Core\Filter\Validator\ArrayItems; +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; + +/** + * @author Julien Deniau + */ +class ArrayItemsTest extends TestCase +{ + public function testNonDefinedFilter() + { + $request = new Request(); + $filter = new ArrayItems(); + + $this->assertEmpty( + $filter->validate('some_filter', [], $request) + ); + } + + public function testEmptyQueryParameter() + { + $request = new Request(['some_filter' => '']); + $filter = new ArrayItems(); + + $this->assertEmpty( + $filter->validate('some_filter', [], $request) + ); + } + + public function testNonMatchingParameter() + { + $filter = new ArrayItems(); + + $filterDefinition = [ + 'swagger' => [ + 'maxItems' => 3, + 'minItems' => 2, + ], + ]; + + $request = new Request(['some_filter' => ['foo', 'bar', 'bar', 'foo']]); + $this->assertEquals( + ['Query parameter "some_filter" must contain less than 3 values'], + $filter->validate('some_filter', $filterDefinition, $request) + ); + + $request = new Request(['some_filter' => ['foo']]); + $this->assertEquals( + ['Query parameter "some_filter" must contain more than 2 values'], + $filter->validate('some_filter', $filterDefinition, $request) + ); + } + + public function testMatchingParameter() + { + $filter = new ArrayItems(); + + $filterDefinition = [ + 'swagger' => [ + 'maxItems' => 3, + 'minItems' => 2, + ], + ]; + + $request = new Request(['some_filter' => ['foo', 'bar']]); + $this->assertEmpty( + $filter->validate('some_filter', $filterDefinition, $request) + ); + + $request = new Request(['some_filter' => ['foo', 'bar', 'baz']]); + $this->assertEmpty( + $filter->validate('some_filter', $filterDefinition, $request) + ); + } + + public function testNonMatchingUniqueItems() + { + $filter = new ArrayItems(); + + $filterDefinition = [ + 'swagger' => [ + 'uniqueItems' => true, + ], + ]; + + $request = new Request(['some_filter' => ['foo', 'bar', 'bar', 'foo']]); + $this->assertEquals( + ['Query parameter "some_filter" must contain unique values'], + $filter->validate('some_filter', $filterDefinition, $request) + ); + } + + public function testMatchingUniqueItems() + { + $filter = new ArrayItems(); + + $filterDefinition = [ + 'swagger' => [ + 'uniqueItems' => true, + ], + ]; + + $request = new Request(['some_filter' => ['foo', 'bar', 'baz']]); + $this->assertEmpty( + $filter->validate('some_filter', $filterDefinition, $request) + ); + } + + public function testSeparators() + { + $filter = new ArrayItems(); + + $filterDefinition = [ + 'swagger' => [ + 'maxItems' => 2, + 'uniqueItems' => true, + 'collectionFormat' => 'csv', + ], + ]; + + $request = new Request(['some_filter' => 'foo,bar,bar']); + $this->assertEquals( + [ + 'Query parameter "some_filter" must contain less than 2 values', + 'Query parameter "some_filter" must contain unique values', + ], + $filter->validate('some_filter', $filterDefinition, $request) + ); + + $filterDefinition['swagger']['collectionFormat'] = 'ssv'; + $this->assertEmpty( + $filter->validate('some_filter', $filterDefinition, $request) + ); + + $filterDefinition['swagger']['collectionFormat'] = 'ssv'; + $request = new Request(['some_filter' => 'foo bar bar']); + $this->assertEquals( + [ + 'Query parameter "some_filter" must contain less than 2 values', + 'Query parameter "some_filter" must contain unique values', + ], + $filter->validate('some_filter', $filterDefinition, $request) + ); + + $filterDefinition['swagger']['collectionFormat'] = 'tsv'; + $request = new Request(['some_filter' => 'foo\tbar\tbar']); + $this->assertEquals( + [ + 'Query parameter "some_filter" must contain less than 2 values', + 'Query parameter "some_filter" must contain unique values', + ], + $filter->validate('some_filter', $filterDefinition, $request) + ); + + $filterDefinition['swagger']['collectionFormat'] = 'pipes'; + $request = new Request(['some_filter' => 'foo|bar|bar']); + $this->assertEquals( + [ + 'Query parameter "some_filter" must contain less than 2 values', + 'Query parameter "some_filter" must contain unique values', + ], + $filter->validate('some_filter', $filterDefinition, $request) + ); + } + + public function testSeparatorsUnknownSeparator() + { + $filter = new ArrayItems(); + + $filterDefinition = [ + 'swagger' => [ + 'maxItems' => 2, + 'uniqueItems' => true, + 'collectionFormat' => 'unknownFormat', + ], + ]; + $request = new Request(['some_filter' => 'foo,bar,bar']); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Unknown collection format unknownFormat'); + + $filter->validate('some_filter', $filterDefinition, $request); + } +} diff --git a/tests/Filter/Validator/BoundsTest.php b/tests/Filter/Validator/BoundsTest.php new file mode 100644 index 00000000000..d993777a911 --- /dev/null +++ b/tests/Filter/Validator/BoundsTest.php @@ -0,0 +1,180 @@ + + * + * 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\Tests\Filter\Validator; + +use ApiPlatform\Core\Filter\Validator\Bounds; +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; + +/** + * @author Julien Deniau + */ +class BoundsTest extends TestCase +{ + public function testNonDefinedFilter() + { + $request = new Request(); + $filter = new Bounds(); + + $this->assertEmpty( + $filter->validate('some_filter', [], $request) + ); + } + + public function testEmptyQueryParameter() + { + $request = new Request(['some_filter' => '']); + $filter = new Bounds(); + + $this->assertEmpty( + $filter->validate('some_filter', [], $request) + ); + } + + public function testNonMatchingMinimum() + { + $request = new Request(['some_filter' => '9']); + $filter = new Bounds(); + + $filterDefinition = [ + 'swagger' => [ + 'minimum' => 10, + ], + ]; + + $this->assertEquals( + ['Query parameter "some_filter" must be greater than or equal to 10'], + $filter->validate('some_filter', $filterDefinition, $request) + ); + + $filterDefinition = [ + 'swagger' => [ + 'minimum' => 10, + 'exclusiveMinimum' => false, + ], + ]; + + $this->assertEquals( + ['Query parameter "some_filter" must be greater than or equal to 10'], + $filter->validate('some_filter', $filterDefinition, $request) + ); + + $filterDefinition = [ + 'swagger' => [ + 'minimum' => 9, + 'exclusiveMinimum' => true, + ], + ]; + + $this->assertEquals( + ['Query parameter "some_filter" must be greater than 9'], + $filter->validate('some_filter', $filterDefinition, $request) + ); + } + + public function testMatchingMinimum() + { + $request = new Request(['some_filter' => '10']); + $filter = new Bounds(); + + $filterDefinition = [ + 'swagger' => [ + 'minimum' => 10, + ], + ]; + + $this->assertEmpty( + $filter->validate('some_filter', $filterDefinition, $request) + ); + + $filterDefinition = [ + 'swagger' => [ + 'minimum' => 9, + 'exclusiveMinimum' => false, + ], + ]; + + $this->assertEmpty( + $filter->validate('some_filter', $filterDefinition, $request) + ); + } + + public function testNonMatchingMaximum() + { + $request = new Request(['some_filter' => '11']); + $filter = new Bounds(); + + $filterDefinition = [ + 'swagger' => [ + 'maximum' => 10, + ], + ]; + + $this->assertEquals( + ['Query parameter "some_filter" must be less than or equal to 10'], + $filter->validate('some_filter', $filterDefinition, $request) + ); + + $filterDefinition = [ + 'swagger' => [ + 'maximum' => 10, + 'exclusiveMaximum' => false, + ], + ]; + + $this->assertEquals( + ['Query parameter "some_filter" must be less than or equal to 10'], + $filter->validate('some_filter', $filterDefinition, $request) + ); + + $filterDefinition = [ + 'swagger' => [ + 'maximum' => 9, + 'exclusiveMaximum' => true, + ], + ]; + + $this->assertEquals( + ['Query parameter "some_filter" must be less than 9'], + $filter->validate('some_filter', $filterDefinition, $request) + ); + } + + public function testMatchingMaximum() + { + $request = new Request(['some_filter' => '10']); + $filter = new Bounds(); + + $filterDefinition = [ + 'swagger' => [ + 'maximum' => 10, + ], + ]; + + $this->assertEmpty( + $filter->validate('some_filter', $filterDefinition, $request) + ); + + $filterDefinition = [ + 'swagger' => [ + 'maximum' => 10, + 'exclusiveMaximum' => false, + ], + ]; + + $this->assertEmpty( + $filter->validate('some_filter', $filterDefinition, $request) + ); + } +} diff --git a/tests/Filter/Validator/EnumTest.php b/tests/Filter/Validator/EnumTest.php new file mode 100644 index 00000000000..e55697e4376 --- /dev/null +++ b/tests/Filter/Validator/EnumTest.php @@ -0,0 +1,77 @@ + + * + * 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\Tests\Filter\Validator; + +use ApiPlatform\Core\Filter\Validator\Enum; +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; + +/** + * @author Julien Deniau + */ +class EnumTest extends TestCase +{ + public function testNonDefinedFilter() + { + $request = new Request(); + $filter = new Enum(); + + $this->assertEmpty( + $filter->validate('some_filter', [], $request) + ); + } + + public function testEmptyQueryParameter() + { + $request = new Request(['some_filter' => '']); + $filter = new Enum(); + + $this->assertEmpty( + $filter->validate('some_filter', [], $request) + ); + } + + public function testNonMatchingParameter() + { + $request = new Request(['some_filter' => 'foobar']); + $filter = new Enum(); + + $filterDefinition = [ + 'swagger' => [ + 'enum' => ['foo', 'bar'], + ], + ]; + + $this->assertEquals( + ['Query parameter "some_filter" must be one of "foo, bar"'], + $filter->validate('some_filter', $filterDefinition, $request) + ); + } + + public function testMatchingParameter() + { + $request = new Request(['some_filter' => 'foo']); + $filter = new Enum(); + + $filterDefinition = [ + 'swagger' => [ + 'enum' => ['foo', 'bar'], + ], + ]; + + $this->assertEmpty( + $filter->validate('some_filter', $filterDefinition, $request) + ); + } +} diff --git a/tests/Filter/Validator/LengthTest.php b/tests/Filter/Validator/LengthTest.php new file mode 100644 index 00000000000..76e60645af2 --- /dev/null +++ b/tests/Filter/Validator/LengthTest.php @@ -0,0 +1,142 @@ + + * + * 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\Tests\Filter\Validator; + +use ApiPlatform\Core\Filter\Validator\Length; +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; + +/** + * @author Julien Deniau + */ +class LengthTest extends TestCase +{ + public function testNonDefinedFilter() + { + $request = new Request(); + $filter = new Length(); + + $this->assertEmpty( + $filter->validate('some_filter', [], $request) + ); + } + + public function testEmptyQueryParameter() + { + $request = new Request(['some_filter' => '']); + $filter = new Length(); + + $this->assertEmpty( + $filter->validate('some_filter', [], $request) + ); + } + + public function testNonMatchingParameter() + { + $filter = new Length(); + + $filterDefinition = [ + 'swagger' => [ + 'minLength' => 3, + 'maxLength' => 5, + ], + ]; + + $this->assertEquals( + ['Query parameter "some_filter" length must be greater than or equal to 3'], + $filter->validate('some_filter', $filterDefinition, new Request(['some_filter' => 'ab'])) + ); + + $this->assertEquals( + ['Query parameter "some_filter" length must be lower than or equal to 5'], + $filter->validate('some_filter', $filterDefinition, new Request(['some_filter' => 'abcdef'])) + ); + } + + public function testNonMatchingParameterWithOnlyOneDefinition() + { + $filter = new Length(); + + $filterDefinition = [ + 'swagger' => [ + 'minLength' => 3, + ], + ]; + + $this->assertEquals( + ['Query parameter "some_filter" length must be greater than or equal to 3'], + $filter->validate('some_filter', $filterDefinition, new Request(['some_filter' => 'ab'])) + ); + + $filterDefinition = [ + 'swagger' => [ + 'maxLength' => 5, + ], + ]; + + $this->assertEquals( + ['Query parameter "some_filter" length must be lower than or equal to 5'], + $filter->validate('some_filter', $filterDefinition, new Request(['some_filter' => 'abcdef'])) + ); + } + + public function testMatchingParameter() + { + $filter = new Length(); + + $filterDefinition = [ + 'swagger' => [ + 'minLength' => 3, + 'maxLength' => 5, + ], + ]; + + $this->assertEmpty( + $filter->validate('some_filter', $filterDefinition, new Request(['some_filter' => 'abc'])) + ); + + $this->assertEmpty( + $filter->validate('some_filter', $filterDefinition, new Request(['some_filter' => 'abcd'])) + ); + + $this->assertEmpty( + $filter->validate('some_filter', $filterDefinition, new Request(['some_filter' => 'abcde'])) + ); + } + + public function testMatchingParameterWithOneDefinition() + { + $filter = new Length(); + + $filterDefinition = [ + 'swagger' => [ + 'minLength' => 3, + ], + ]; + + $this->assertEmpty( + $filter->validate('some_filter', $filterDefinition, new Request(['some_filter' => 'abc'])) + ); + + $filterDefinition = [ + 'swagger' => [ + 'maxLength' => 5, + ], + ]; + + $this->assertEmpty( + $filter->validate('some_filter', $filterDefinition, new Request(['some_filter' => 'abcde'])) + ); + } +} diff --git a/tests/Filter/Validator/MultipleOfTest.php b/tests/Filter/Validator/MultipleOfTest.php new file mode 100644 index 00000000000..a90386db765 --- /dev/null +++ b/tests/Filter/Validator/MultipleOfTest.php @@ -0,0 +1,77 @@ + + * + * 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\Tests\Filter\Validator; + +use ApiPlatform\Core\Filter\Validator\MultipleOf; +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; + +/** + * @author Julien Deniau + */ +class MultipleOfTest extends TestCase +{ + public function testNonDefinedFilter() + { + $request = new Request(); + $filter = new MultipleOf(); + + $this->assertEmpty( + $filter->validate('some_filter', [], $request) + ); + } + + public function testEmptyQueryParameter() + { + $request = new Request(['some_filter' => '']); + $filter = new MultipleOf(); + + $this->assertEmpty( + $filter->validate('some_filter', [], $request) + ); + } + + public function testNonMatchingParameter() + { + $request = new Request(['some_filter' => '8']); + $filter = new MultipleOf(); + + $filterDefinition = [ + 'swagger' => [ + 'multipleOf' => 3, + ], + ]; + + $this->assertEquals( + ['Query parameter "some_filter" must multiple of 3'], + $filter->validate('some_filter', $filterDefinition, $request) + ); + } + + public function testMatchingParameter() + { + $request = new Request(['some_filter' => '8']); + $filter = new MultipleOf(); + + $filterDefinition = [ + 'swagger' => [ + 'multipleOf' => 4, + ], + ]; + + $this->assertEmpty( + $filter->validate('some_filter', $filterDefinition, $request) + ); + } +} diff --git a/tests/Filter/Validator/PatternTest.php b/tests/Filter/Validator/PatternTest.php new file mode 100644 index 00000000000..02a924a8f5b --- /dev/null +++ b/tests/Filter/Validator/PatternTest.php @@ -0,0 +1,102 @@ + + * + * 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\Tests\Filter\Validator; + +use ApiPlatform\Core\Filter\Validator\Pattern; +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; + +/** + * @author Julien Deniau + */ +class PatternTest extends TestCase +{ + public function testNonDefinedFilter() + { + $request = new Request(); + $filter = new Pattern(); + + $this->assertEmpty( + $filter->validate('some_filter', [], $request) + ); + } + + public function testFilterWithEmptyValue() + { + $filter = new Pattern(); + + $explicitFilterDefinition = [ + 'swagger' => [ + 'pattern' => '/foo/', + ], + ]; + + $this->assertEmpty( + $filter->validate('some_filter', $explicitFilterDefinition, new Request(['some_filter' => ''])) + ); + + $weirdParameter = new \stdClass(); + $weirdParameter->foo = 'non string value should not exists'; + $this->assertEmpty( + $filter->validate('some_filter', $explicitFilterDefinition, new Request(['some_filter' => $weirdParameter])) + ); + } + + public function testFilterWithZeroAsParameter() + { + $filter = new Pattern(); + + $explicitFilterDefinition = [ + 'swagger' => [ + 'pattern' => '/foo/', + ], + ]; + + $this->assertEquals( + ['Query parameter "some_filter" must match pattern /foo/'], + $filter->validate('some_filter', $explicitFilterDefinition, new Request(['some_filter' => '0'])) + ); + } + + public function testFilterWithNonMatchingValue() + { + $filter = new Pattern(); + + $explicitFilterDefinition = [ + 'swagger' => [ + 'pattern' => '/foo/', + ], + ]; + + $this->assertEquals( + ['Query parameter "some_filter" must match pattern /foo/'], + $filter->validate('some_filter', $explicitFilterDefinition, new Request(['some_filter' => 'bar'])) + ); + } + + public function testFilterWithNonchingValue() + { + $filter = new Pattern(); + + $explicitFilterDefinition = [ + 'swagger' => [ + 'pattern' => '/foo \d+/', + ], + ]; + + $this->assertEmpty( + $filter->validate('some_filter', $explicitFilterDefinition, new Request(['some_filter' => 'this is a foo '.random_int(0, 10).' and it should match'])) + ); + } +} diff --git a/tests/Filter/Validator/RequiredTest.php b/tests/Filter/Validator/RequiredTest.php new file mode 100644 index 00000000000..b2c1596c144 --- /dev/null +++ b/tests/Filter/Validator/RequiredTest.php @@ -0,0 +1,105 @@ + + * + * 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\Tests\Filter\Validator; + +use ApiPlatform\Core\Filter\Validator\Required; +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; + +/** + * Class RequiredTest. + * + * @author Julien Deniau + */ +class RequiredTest extends TestCase +{ + public function testNonRequiredFilter() + { + $request = new Request(); + $filter = new Required(); + + $this->assertEmpty( + $filter->validate('some_filter', [], $request) + ); + + $this->assertEmpty( + $filter->validate('some_filter', ['required' => false], $request) + ); + } + + public function testRequiredFilterNotInQuery() + { + $request = new Request(); + $filter = new Required(); + + $this->assertEquals( + ['Query parameter "some_filter" is required'], + $filter->validate('some_filter', ['required' => true], $request) + ); + } + + public function testRequiredFilterIsPresent() + { + $request = new Request(['some_filter' => 'some_value']); + $filter = new Required(); + + $this->assertEmpty( + $filter->validate('some_filter', ['required' => true], $request) + ); + } + + public function testEmptyValueNotAllowed() + { + $request = new Request(['some_filter' => '']); + $filter = new Required(); + + $explicitFilterDefinition = [ + 'required' => true, + 'swagger' => [ + 'allowEmptyValue' => false, + ], + ]; + + $this->assertEquals( + ['Query parameter "some_filter" does not allow empty value'], + $filter->validate('some_filter', $explicitFilterDefinition, $request) + ); + + $implicitFilterDefinition = [ + 'required' => true, + ]; + + $this->assertEquals( + ['Query parameter "some_filter" does not allow empty value'], + $filter->validate('some_filter', $implicitFilterDefinition, $request) + ); + } + + public function testEmptyValueAllowed() + { + $request = new Request(['some_filter' => '']); + $filter = new Required(); + + $explicitFilterDefinition = [ + 'required' => true, + 'swagger' => [ + 'allowEmptyValue' => true, + ], + ]; + + $this->assertEmpty( + $filter->validate('some_filter', $explicitFilterDefinition, $request) + ); + } +} From dbde28a038edea0a2b8e353834aec00cf359c7fd Mon Sep 17 00:00:00 2001 From: Julien Deniau Date: Wed, 26 Dec 2018 15:52:35 +0100 Subject: [PATCH 036/160] use float instead of number in Filters::getDescription --- tests/Fixtures/TestBundle/Filter/BoundsFilter.php | 8 ++++---- tests/Fixtures/TestBundle/Filter/MultipleOfFilter.php | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/Fixtures/TestBundle/Filter/BoundsFilter.php b/tests/Fixtures/TestBundle/Filter/BoundsFilter.php index 4327e8377ea..ef46fc29f36 100644 --- a/tests/Fixtures/TestBundle/Filter/BoundsFilter.php +++ b/tests/Fixtures/TestBundle/Filter/BoundsFilter.php @@ -29,7 +29,7 @@ public function getDescription(string $resourceClass): array return [ 'maximum' => [ 'property' => 'maximum', - 'type' => 'number', + 'type' => 'float', 'required' => false, 'swagger' => [ 'maximum' => 10, @@ -37,7 +37,7 @@ public function getDescription(string $resourceClass): array ], 'exclusiveMaximum' => [ 'property' => 'maximum', - 'type' => 'number', + 'type' => 'float', 'required' => false, 'swagger' => [ 'maximum' => 10, @@ -46,7 +46,7 @@ public function getDescription(string $resourceClass): array ], 'minimum' => [ 'property' => 'minimum', - 'type' => 'number', + 'type' => 'float', 'required' => false, 'swagger' => [ 'minimum' => 5, @@ -54,7 +54,7 @@ public function getDescription(string $resourceClass): array ], 'exclusiveMinimum' => [ 'property' => 'exclusiveMinimum', - 'type' => 'number', + 'type' => 'float', 'required' => false, 'swagger' => [ 'minimum' => 5, diff --git a/tests/Fixtures/TestBundle/Filter/MultipleOfFilter.php b/tests/Fixtures/TestBundle/Filter/MultipleOfFilter.php index 6f0703bec8c..f806a99950d 100644 --- a/tests/Fixtures/TestBundle/Filter/MultipleOfFilter.php +++ b/tests/Fixtures/TestBundle/Filter/MultipleOfFilter.php @@ -29,7 +29,7 @@ public function getDescription(string $resourceClass): array return [ 'multiple-of' => [ 'property' => 'multiple-of', - 'type' => 'number', + 'type' => 'float', 'required' => false, 'swagger' => [ 'multipleOf' => 2, From 7f4f7e6df67ee639e1ac88befece657e4b3db1a4 Mon Sep 17 00:00:00 2001 From: Julien Deniau Date: Wed, 26 Dec 2018 15:57:11 +0100 Subject: [PATCH 037/160] fix phpstan / cs-fixer returns --- src/Filter/Validator/ArrayItems.php | 2 +- src/Filter/Validator/Required.php | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Filter/Validator/ArrayItems.php b/src/Filter/Validator/ArrayItems.php index 42dfa298eed..960fa39f45e 100644 --- a/src/Filter/Validator/ArrayItems.php +++ b/src/Filter/Validator/ArrayItems.php @@ -61,7 +61,7 @@ private function getValue(string $name, array $filterDescription, Request $reque $collectionFormat = $filterDescription['swagger']['collectionFormat'] ?? 'csv'; - return explode(self::getSeparator($collectionFormat), $value); + return explode(self::getSeparator($collectionFormat), $value) ?: []; } private static function getSeparator(string $collectionFormat): string diff --git a/src/Filter/Validator/Required.php b/src/Filter/Validator/Required.php index ff6332bb590..d290881b3ea 100644 --- a/src/Filter/Validator/Required.php +++ b/src/Filter/Validator/Required.php @@ -60,12 +60,12 @@ private function requestHasQueryParameter(Request $request, string $name): bool if (\is_array($matches[$rootName])) { $keyName = array_keys($matches[$rootName])[0]; - $queryParameter = $request->query->get($rootName); + $queryParameter = $request->query->get((string) $rootName); return \is_array($queryParameter) && isset($queryParameter[$keyName]); } - return $request->query->has($rootName); + return $request->query->has((string) $rootName); } /** @@ -87,7 +87,7 @@ private function requestGetQueryParameter(Request $request, string $name) if (\is_array($matches[$rootName])) { $keyName = array_keys($matches[$rootName])[0]; - $queryParameter = $request->query->get($rootName); + $queryParameter = $request->query->get((string) $rootName); if (\is_array($queryParameter) && isset($queryParameter[$keyName])) { return $queryParameter[$keyName]; @@ -96,6 +96,6 @@ private function requestGetQueryParameter(Request $request, string $name) return null; } - return $request->query->get($rootName); + return $request->query->get((string) $rootName); } } From 7e4f145952eda910faee627e9be1ee12a32cecf8 Mon Sep 17 00:00:00 2001 From: Julien Deniau Date: Fri, 25 Jan 2019 11:36:31 +0100 Subject: [PATCH 038/160] add filter tests for mongodb too --- .../TestBundle/Document/FilterValidator.php | 16 +++++++++++++++- .../TestBundle/Filter/ArrayItemsFilter.php | 3 +++ .../Fixtures/TestBundle/Filter/BoundsFilter.php | 3 +++ tests/Fixtures/TestBundle/Filter/EnumFilter.php | 3 +++ .../Fixtures/TestBundle/Filter/LengthFilter.php | 3 +++ .../TestBundle/Filter/MultipleOfFilter.php | 3 +++ .../Fixtures/TestBundle/Filter/PatternFilter.php | 3 +++ .../Filter/RequiredAllowEmptyFilter.php | 3 +++ 8 files changed, 36 insertions(+), 1 deletion(-) diff --git a/tests/Fixtures/TestBundle/Document/FilterValidator.php b/tests/Fixtures/TestBundle/Document/FilterValidator.php index 722ac9f849f..1756965196a 100644 --- a/tests/Fixtures/TestBundle/Document/FilterValidator.php +++ b/tests/Fixtures/TestBundle/Document/FilterValidator.php @@ -15,6 +15,13 @@ use ApiPlatform\Core\Annotation\ApiProperty; use ApiPlatform\Core\Annotation\ApiResource; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter\ArrayItemsFilter; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter\BoundsFilter; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter\EnumFilter; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter\LengthFilter; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter\MultipleOfFilter; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter\PatternFilter; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter\RequiredAllowEmptyFilter; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter\RequiredFilter; use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; @@ -26,7 +33,14 @@ * * @ApiResource(attributes={ * "filters"={ - * RequiredFilter::class + * ArrayItemsFilter::class, + * BoundsFilter::class, + * EnumFilter::class, + * LengthFilter::class, + * MultipleOfFilter::class, + * PatternFilter::class, + * RequiredFilter::class, + * RequiredAllowEmptyFilter::class * } * }) * @ODM\Document diff --git a/tests/Fixtures/TestBundle/Filter/ArrayItemsFilter.php b/tests/Fixtures/TestBundle/Filter/ArrayItemsFilter.php index 7feb9b3409f..848eaa34104 100644 --- a/tests/Fixtures/TestBundle/Filter/ArrayItemsFilter.php +++ b/tests/Fixtures/TestBundle/Filter/ArrayItemsFilter.php @@ -13,12 +13,15 @@ namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter; +use ApiPlatform\Core\Bridge\Doctrine\Common\PropertyHelperTrait; use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\AbstractFilter; use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface; use Doctrine\ORM\QueryBuilder; class ArrayItemsFilter extends AbstractFilter { + use PropertyHelperTrait; + protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null) { } diff --git a/tests/Fixtures/TestBundle/Filter/BoundsFilter.php b/tests/Fixtures/TestBundle/Filter/BoundsFilter.php index ef46fc29f36..9c3cca1984a 100644 --- a/tests/Fixtures/TestBundle/Filter/BoundsFilter.php +++ b/tests/Fixtures/TestBundle/Filter/BoundsFilter.php @@ -13,12 +13,15 @@ namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter; +use ApiPlatform\Core\Bridge\Doctrine\Common\PropertyHelperTrait; use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\AbstractFilter; use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface; use Doctrine\ORM\QueryBuilder; class BoundsFilter extends AbstractFilter { + use PropertyHelperTrait; + protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null) { } diff --git a/tests/Fixtures/TestBundle/Filter/EnumFilter.php b/tests/Fixtures/TestBundle/Filter/EnumFilter.php index a2fe49b2598..1c4bd33fa27 100644 --- a/tests/Fixtures/TestBundle/Filter/EnumFilter.php +++ b/tests/Fixtures/TestBundle/Filter/EnumFilter.php @@ -13,12 +13,15 @@ namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter; +use ApiPlatform\Core\Bridge\Doctrine\Common\PropertyHelperTrait; use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\AbstractFilter; use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface; use Doctrine\ORM\QueryBuilder; class EnumFilter extends AbstractFilter { + use PropertyHelperTrait; + protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null) { } diff --git a/tests/Fixtures/TestBundle/Filter/LengthFilter.php b/tests/Fixtures/TestBundle/Filter/LengthFilter.php index be9c30af0b2..6d44799156f 100644 --- a/tests/Fixtures/TestBundle/Filter/LengthFilter.php +++ b/tests/Fixtures/TestBundle/Filter/LengthFilter.php @@ -13,12 +13,15 @@ namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter; +use ApiPlatform\Core\Bridge\Doctrine\Common\PropertyHelperTrait; use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\AbstractFilter; use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface; use Doctrine\ORM\QueryBuilder; class LengthFilter extends AbstractFilter { + use PropertyHelperTrait; + protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null) { } diff --git a/tests/Fixtures/TestBundle/Filter/MultipleOfFilter.php b/tests/Fixtures/TestBundle/Filter/MultipleOfFilter.php index f806a99950d..4918188526e 100644 --- a/tests/Fixtures/TestBundle/Filter/MultipleOfFilter.php +++ b/tests/Fixtures/TestBundle/Filter/MultipleOfFilter.php @@ -13,12 +13,15 @@ namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter; +use ApiPlatform\Core\Bridge\Doctrine\Common\PropertyHelperTrait; use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\AbstractFilter; use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface; use Doctrine\ORM\QueryBuilder; class MultipleOfFilter extends AbstractFilter { + use PropertyHelperTrait; + protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null) { } diff --git a/tests/Fixtures/TestBundle/Filter/PatternFilter.php b/tests/Fixtures/TestBundle/Filter/PatternFilter.php index ccb9f56e731..1cb136520d6 100644 --- a/tests/Fixtures/TestBundle/Filter/PatternFilter.php +++ b/tests/Fixtures/TestBundle/Filter/PatternFilter.php @@ -13,12 +13,15 @@ namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter; +use ApiPlatform\Core\Bridge\Doctrine\Common\PropertyHelperTrait; use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\AbstractFilter; use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface; use Doctrine\ORM\QueryBuilder; class PatternFilter extends AbstractFilter { + use PropertyHelperTrait; + protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null) { } diff --git a/tests/Fixtures/TestBundle/Filter/RequiredAllowEmptyFilter.php b/tests/Fixtures/TestBundle/Filter/RequiredAllowEmptyFilter.php index d0dc25882dd..8727b754d89 100644 --- a/tests/Fixtures/TestBundle/Filter/RequiredAllowEmptyFilter.php +++ b/tests/Fixtures/TestBundle/Filter/RequiredAllowEmptyFilter.php @@ -13,12 +13,15 @@ namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter; +use ApiPlatform\Core\Bridge\Doctrine\Common\PropertyHelperTrait; use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\AbstractFilter; use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface; use Doctrine\ORM\QueryBuilder; class RequiredAllowEmptyFilter extends AbstractFilter { + use PropertyHelperTrait; + protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null) { } From 9cc5965bbe4b4223d78995411c17d0c8d1521269 Mon Sep 17 00:00:00 2001 From: Julien Deniau Date: Mon, 28 Jan 2019 12:39:19 +0100 Subject: [PATCH 039/160] make validator classes final --- src/Bridge/Symfony/Validator/Validator.php | 2 ++ src/Filter/QueryParameterValidator.php | 2 +- src/Filter/Validator/ArrayItems.php | 2 +- src/Filter/Validator/Bounds.php | 2 +- src/Filter/Validator/Enum.php | 2 +- src/Filter/Validator/Length.php | 2 +- src/Filter/Validator/MultipleOf.php | 2 +- src/Filter/Validator/Pattern.php | 2 +- src/Filter/Validator/Required.php | 2 +- 9 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/Bridge/Symfony/Validator/Validator.php b/src/Bridge/Symfony/Validator/Validator.php index 84a7e3a36dc..b804999e732 100644 --- a/src/Bridge/Symfony/Validator/Validator.php +++ b/src/Bridge/Symfony/Validator/Validator.php @@ -23,6 +23,8 @@ * Validates an item using the Symfony validator component. * * @author Kévin Dunglas + * + * @final */ class Validator implements ValidatorInterface { diff --git a/src/Filter/QueryParameterValidator.php b/src/Filter/QueryParameterValidator.php index 62f6f5d0e4a..7eb34c7791e 100644 --- a/src/Filter/QueryParameterValidator.php +++ b/src/Filter/QueryParameterValidator.php @@ -44,7 +44,7 @@ public function __construct(ContainerInterface $filterLocator) ]; } - public function validateFilters(string $resourceClass, array $resourceFilters, Request $request) + public function validateFilters(string $resourceClass, array $resourceFilters, Request $request): void { $errorList = []; foreach ($resourceFilters as $filterId) { diff --git a/src/Filter/Validator/ArrayItems.php b/src/Filter/Validator/ArrayItems.php index 960fa39f45e..517be613f84 100644 --- a/src/Filter/Validator/ArrayItems.php +++ b/src/Filter/Validator/ArrayItems.php @@ -15,7 +15,7 @@ use Symfony\Component\HttpFoundation\Request; -class ArrayItems implements ValidatorInterface +final class ArrayItems implements ValidatorInterface { public function validate(string $name, array $filterDescription, Request $request): array { diff --git a/src/Filter/Validator/Bounds.php b/src/Filter/Validator/Bounds.php index 119d4c9dba6..a77481a4506 100644 --- a/src/Filter/Validator/Bounds.php +++ b/src/Filter/Validator/Bounds.php @@ -15,7 +15,7 @@ use Symfony\Component\HttpFoundation\Request; -class Bounds implements ValidatorInterface +final class Bounds implements ValidatorInterface { public function validate(string $name, array $filterDescription, Request $request): array { diff --git a/src/Filter/Validator/Enum.php b/src/Filter/Validator/Enum.php index abc4b8b89df..8c7bd6d26bc 100644 --- a/src/Filter/Validator/Enum.php +++ b/src/Filter/Validator/Enum.php @@ -15,7 +15,7 @@ use Symfony\Component\HttpFoundation\Request; -class Enum implements ValidatorInterface +final class Enum implements ValidatorInterface { public function validate(string $name, array $filterDescription, Request $request): array { diff --git a/src/Filter/Validator/Length.php b/src/Filter/Validator/Length.php index a64d370cec1..d129ef1dbb1 100644 --- a/src/Filter/Validator/Length.php +++ b/src/Filter/Validator/Length.php @@ -15,7 +15,7 @@ use Symfony\Component\HttpFoundation\Request; -class Length implements ValidatorInterface +final class Length implements ValidatorInterface { public function validate(string $name, array $filterDescription, Request $request): array { diff --git a/src/Filter/Validator/MultipleOf.php b/src/Filter/Validator/MultipleOf.php index a4f7ee90fa1..4d9e397b03d 100644 --- a/src/Filter/Validator/MultipleOf.php +++ b/src/Filter/Validator/MultipleOf.php @@ -15,7 +15,7 @@ use Symfony\Component\HttpFoundation\Request; -class MultipleOf implements ValidatorInterface +final class MultipleOf implements ValidatorInterface { public function validate(string $name, array $filterDescription, Request $request): array { diff --git a/src/Filter/Validator/Pattern.php b/src/Filter/Validator/Pattern.php index c6c46667416..d5dc6d3d736 100644 --- a/src/Filter/Validator/Pattern.php +++ b/src/Filter/Validator/Pattern.php @@ -15,7 +15,7 @@ use Symfony\Component\HttpFoundation\Request; -class Pattern implements ValidatorInterface +final class Pattern implements ValidatorInterface { public function validate(string $name, array $filterDescription, Request $request): array { diff --git a/src/Filter/Validator/Required.php b/src/Filter/Validator/Required.php index d290881b3ea..466fe8fac1f 100644 --- a/src/Filter/Validator/Required.php +++ b/src/Filter/Validator/Required.php @@ -15,7 +15,7 @@ use Symfony\Component\HttpFoundation\Request; -class Required implements ValidatorInterface +final class Required implements ValidatorInterface { public function validate(string $name, array $filterDescription, Request $request): array { From 5d8cf4937f02ef82ac329777fe3f015007f6324c Mon Sep 17 00:00:00 2001 From: Julien Deniau Date: Mon, 28 Jan 2019 14:44:59 +0100 Subject: [PATCH 040/160] Use query parameters array instead of Symfony Request --- src/Filter/QueryParameterValidator.php | 2 +- src/Filter/Validator/ArrayItems.php | 15 ++++++------ src/Filter/Validator/Bounds.php | 9 ++++--- src/Filter/Validator/Enum.php | 9 ++++--- src/Filter/Validator/Length.php | 15 ++++++------ src/Filter/Validator/MultipleOf.php | 9 ++++--- src/Filter/Validator/Pattern.php | 9 ++++--- src/Filter/Validator/Required.php | 25 ++++++++++--------- src/Filter/Validator/ValidatorInterface.php | 9 ++++--- tests/Filter/Validator/ArrayItemsTest.php | 27 ++++++++++----------- tests/Filter/Validator/BoundsTest.php | 15 +++++------- tests/Filter/Validator/EnumTest.php | 13 +++------- tests/Filter/Validator/LengthTest.php | 25 +++++++++---------- tests/Filter/Validator/MultipleOfTest.php | 10 +++----- tests/Filter/Validator/PatternTest.php | 14 +++++------ tests/Filter/Validator/RequiredTest.php | 13 +++++----- 16 files changed, 107 insertions(+), 112 deletions(-) diff --git a/src/Filter/QueryParameterValidator.php b/src/Filter/QueryParameterValidator.php index 7eb34c7791e..2aac20e1e2b 100644 --- a/src/Filter/QueryParameterValidator.php +++ b/src/Filter/QueryParameterValidator.php @@ -54,7 +54,7 @@ public function validateFilters(string $resourceClass, array $resourceFilters, R foreach ($filter->getDescription($resourceClass) as $name => $data) { foreach ($this->validators as $validator) { - $errorList = array_merge($errorList, $validator->validate($name, $data, $request)); + $errorList = array_merge($errorList, $validator->validate($name, $data, $request->query->all())); } } } diff --git a/src/Filter/Validator/ArrayItems.php b/src/Filter/Validator/ArrayItems.php index 517be613f84..e29e7200f84 100644 --- a/src/Filter/Validator/ArrayItems.php +++ b/src/Filter/Validator/ArrayItems.php @@ -13,13 +13,14 @@ namespace ApiPlatform\Core\Filter\Validator; -use Symfony\Component\HttpFoundation\Request; - final class ArrayItems implements ValidatorInterface { - public function validate(string $name, array $filterDescription, Request $request): array + /** + * {@inheritdoc} + */ + public function validate(string $name, array $filterDescription, array $queryParameters): array { - if (!$request->query->has($name)) { + if (!\array_key_exists($name, $queryParameters)) { return []; } @@ -29,7 +30,7 @@ public function validate(string $name, array $filterDescription, Request $reques $errorList = []; - $value = $this->getValue($name, $filterDescription, $request); + $value = $this->getValue($name, $filterDescription, $queryParameters); $nbItems = \count($value); if (null !== $maxItems && $nbItems > $maxItems) { @@ -47,9 +48,9 @@ public function validate(string $name, array $filterDescription, Request $reques return $errorList; } - private function getValue(string $name, array $filterDescription, Request $request): array + private function getValue(string $name, array $filterDescription, array $queryParameters): array { - $value = $request->query->get($name); + $value = $queryParameters[$name] ?? null; if (empty($value) && '0' !== $value) { return []; diff --git a/src/Filter/Validator/Bounds.php b/src/Filter/Validator/Bounds.php index a77481a4506..bb7c974b2c1 100644 --- a/src/Filter/Validator/Bounds.php +++ b/src/Filter/Validator/Bounds.php @@ -13,13 +13,14 @@ namespace ApiPlatform\Core\Filter\Validator; -use Symfony\Component\HttpFoundation\Request; - final class Bounds implements ValidatorInterface { - public function validate(string $name, array $filterDescription, Request $request): array + /** + * {@inheritdoc} + */ + public function validate(string $name, array $filterDescription, array $queryParameters): array { - $value = $request->query->get($name); + $value = $queryParameters[$name] ?? null; if (empty($value) && '0' !== $value) { return []; } diff --git a/src/Filter/Validator/Enum.php b/src/Filter/Validator/Enum.php index 8c7bd6d26bc..5393de43ad0 100644 --- a/src/Filter/Validator/Enum.php +++ b/src/Filter/Validator/Enum.php @@ -13,13 +13,14 @@ namespace ApiPlatform\Core\Filter\Validator; -use Symfony\Component\HttpFoundation\Request; - final class Enum implements ValidatorInterface { - public function validate(string $name, array $filterDescription, Request $request): array + /** + * {@inheritdoc} + */ + public function validate(string $name, array $filterDescription, array $queryParameters): array { - $value = $request->query->get($name); + $value = $queryParameters[$name] ?? null; if (empty($value) && '0' !== $value || !\is_string($value)) { return []; } diff --git a/src/Filter/Validator/Length.php b/src/Filter/Validator/Length.php index d129ef1dbb1..6897ef57e02 100644 --- a/src/Filter/Validator/Length.php +++ b/src/Filter/Validator/Length.php @@ -13,20 +13,21 @@ namespace ApiPlatform\Core\Filter\Validator; -use Symfony\Component\HttpFoundation\Request; - final class Length implements ValidatorInterface { - public function validate(string $name, array $filterDescription, Request $request): array + /** + * {@inheritdoc} + */ + public function validate(string $name, array $filterDescription, array $queryParameters): array { - $maxLength = $filterDescription['swagger']['maxLength'] ?? null; - $minLength = $filterDescription['swagger']['minLength'] ?? null; - - $value = $request->query->get($name); + $value = $queryParameters[$name] ?? null; if (empty($value) && '0' !== $value || !\is_string($value)) { return []; } + $maxLength = $filterDescription['swagger']['maxLength'] ?? null; + $minLength = $filterDescription['swagger']['minLength'] ?? null; + $errorList = []; if (null !== $maxLength && mb_strlen($value) > $maxLength) { diff --git a/src/Filter/Validator/MultipleOf.php b/src/Filter/Validator/MultipleOf.php index 4d9e397b03d..75235007bdd 100644 --- a/src/Filter/Validator/MultipleOf.php +++ b/src/Filter/Validator/MultipleOf.php @@ -13,13 +13,14 @@ namespace ApiPlatform\Core\Filter\Validator; -use Symfony\Component\HttpFoundation\Request; - final class MultipleOf implements ValidatorInterface { - public function validate(string $name, array $filterDescription, Request $request): array + /** + * {@inheritdoc} + */ + public function validate(string $name, array $filterDescription, array $queryParameters): array { - $value = $request->query->get($name); + $value = $queryParameters[$name] ?? null; if (empty($value) && '0' !== $value || !\is_string($value)) { return []; } diff --git a/src/Filter/Validator/Pattern.php b/src/Filter/Validator/Pattern.php index d5dc6d3d736..5346feac04b 100644 --- a/src/Filter/Validator/Pattern.php +++ b/src/Filter/Validator/Pattern.php @@ -13,13 +13,14 @@ namespace ApiPlatform\Core\Filter\Validator; -use Symfony\Component\HttpFoundation\Request; - final class Pattern implements ValidatorInterface { - public function validate(string $name, array $filterDescription, Request $request): array + /** + * {@inheritdoc} + */ + public function validate(string $name, array $filterDescription, array $queryParameters): array { - $value = $request->query->get($name); + $value = $queryParameters[$name] ?? null; if (empty($value) && '0' !== $value || !\is_string($value)) { return []; } diff --git a/src/Filter/Validator/Required.php b/src/Filter/Validator/Required.php index 466fe8fac1f..b018f59e4b9 100644 --- a/src/Filter/Validator/Required.php +++ b/src/Filter/Validator/Required.php @@ -13,11 +13,12 @@ namespace ApiPlatform\Core\Filter\Validator; -use Symfony\Component\HttpFoundation\Request; - final class Required implements ValidatorInterface { - public function validate(string $name, array $filterDescription, Request $request): array + /** + * {@inheritdoc} + */ + public function validate(string $name, array $filterDescription, array $queryParameters): array { // filter is not required, the `checkRequired` method can not break if (!($filterDescription['required'] ?? false)) { @@ -25,14 +26,14 @@ public function validate(string $name, array $filterDescription, Request $reques } // if query param is not given, then break - if (!$this->requestHasQueryParameter($request, $name)) { + if (!$this->requestHasQueryParameter($queryParameters, $name)) { return [ sprintf('Query parameter "%s" is required', $name), ]; } // if query param is empty and the configuration does not allow it - if (!($filterDescription['swagger']['allowEmptyValue'] ?? false) && empty($this->requestGetQueryParameter($request, $name))) { + if (!($filterDescription['swagger']['allowEmptyValue'] ?? false) && empty($this->requestGetQueryParameter($queryParameters, $name))) { return [ sprintf('Query parameter "%s" does not allow empty value', $name), ]; @@ -44,7 +45,7 @@ public function validate(string $name, array $filterDescription, Request $reques /** * Test if request has required parameter. */ - private function requestHasQueryParameter(Request $request, string $name): bool + private function requestHasQueryParameter(array $queryParameters, string $name): bool { $matches = []; parse_str($name, $matches); @@ -60,18 +61,20 @@ private function requestHasQueryParameter(Request $request, string $name): bool if (\is_array($matches[$rootName])) { $keyName = array_keys($matches[$rootName])[0]; - $queryParameter = $request->query->get((string) $rootName); + $queryParameter = $queryParameters[(string) $rootName]; return \is_array($queryParameter) && isset($queryParameter[$keyName]); } - return $request->query->has((string) $rootName); + return \array_key_exists((string) $rootName, $queryParameters); } /** * Test if required filter is valid. It validates array notation too like "required[bar]". + * + * @return ?mixed */ - private function requestGetQueryParameter(Request $request, string $name) + private function requestGetQueryParameter(array $queryParameters, string $name) { $matches = []; parse_str($name, $matches); @@ -87,7 +90,7 @@ private function requestGetQueryParameter(Request $request, string $name) if (\is_array($matches[$rootName])) { $keyName = array_keys($matches[$rootName])[0]; - $queryParameter = $request->query->get((string) $rootName); + $queryParameter = $queryParameters[(string) $rootName]; if (\is_array($queryParameter) && isset($queryParameter[$keyName])) { return $queryParameter[$keyName]; @@ -96,6 +99,6 @@ private function requestGetQueryParameter(Request $request, string $name) return null; } - return $request->query->get((string) $rootName); + return $queryParameters[(string) $rootName]; } } diff --git a/src/Filter/Validator/ValidatorInterface.php b/src/Filter/Validator/ValidatorInterface.php index 33740725059..2a2113362b0 100644 --- a/src/Filter/Validator/ValidatorInterface.php +++ b/src/Filter/Validator/ValidatorInterface.php @@ -13,9 +13,12 @@ namespace ApiPlatform\Core\Filter\Validator; -use Symfony\Component\HttpFoundation\Request; - interface ValidatorInterface { - public function validate(string $name, array $filterDescription, Request $request): array; + /** + * @var string the parameter name to validate + * @var array $filterDescription the filter descriptions as returned by `ApiPlatform\Core\Api\FilterInterface::getDescription()` + * @var array $queryParameters the list of query parameter + */ + public function validate(string $name, array $filterDescription, array $queryParameters): array; } diff --git a/tests/Filter/Validator/ArrayItemsTest.php b/tests/Filter/Validator/ArrayItemsTest.php index 6e4d2a3e9a4..66aee6ee7e5 100644 --- a/tests/Filter/Validator/ArrayItemsTest.php +++ b/tests/Filter/Validator/ArrayItemsTest.php @@ -15,7 +15,6 @@ use ApiPlatform\Core\Filter\Validator\ArrayItems; use PHPUnit\Framework\TestCase; -use Symfony\Component\HttpFoundation\Request; /** * @author Julien Deniau @@ -24,7 +23,7 @@ class ArrayItemsTest extends TestCase { public function testNonDefinedFilter() { - $request = new Request(); + $request = []; $filter = new ArrayItems(); $this->assertEmpty( @@ -34,7 +33,7 @@ public function testNonDefinedFilter() public function testEmptyQueryParameter() { - $request = new Request(['some_filter' => '']); + $request = ['some_filter' => '']; $filter = new ArrayItems(); $this->assertEmpty( @@ -53,13 +52,13 @@ public function testNonMatchingParameter() ], ]; - $request = new Request(['some_filter' => ['foo', 'bar', 'bar', 'foo']]); + $request = ['some_filter' => ['foo', 'bar', 'bar', 'foo']]; $this->assertEquals( ['Query parameter "some_filter" must contain less than 3 values'], $filter->validate('some_filter', $filterDefinition, $request) ); - $request = new Request(['some_filter' => ['foo']]); + $request = ['some_filter' => ['foo']]; $this->assertEquals( ['Query parameter "some_filter" must contain more than 2 values'], $filter->validate('some_filter', $filterDefinition, $request) @@ -77,12 +76,12 @@ public function testMatchingParameter() ], ]; - $request = new Request(['some_filter' => ['foo', 'bar']]); + $request = ['some_filter' => ['foo', 'bar']]; $this->assertEmpty( $filter->validate('some_filter', $filterDefinition, $request) ); - $request = new Request(['some_filter' => ['foo', 'bar', 'baz']]); + $request = ['some_filter' => ['foo', 'bar', 'baz']]; $this->assertEmpty( $filter->validate('some_filter', $filterDefinition, $request) ); @@ -98,7 +97,7 @@ public function testNonMatchingUniqueItems() ], ]; - $request = new Request(['some_filter' => ['foo', 'bar', 'bar', 'foo']]); + $request = ['some_filter' => ['foo', 'bar', 'bar', 'foo']]; $this->assertEquals( ['Query parameter "some_filter" must contain unique values'], $filter->validate('some_filter', $filterDefinition, $request) @@ -115,7 +114,7 @@ public function testMatchingUniqueItems() ], ]; - $request = new Request(['some_filter' => ['foo', 'bar', 'baz']]); + $request = ['some_filter' => ['foo', 'bar', 'baz']]; $this->assertEmpty( $filter->validate('some_filter', $filterDefinition, $request) ); @@ -133,7 +132,7 @@ public function testSeparators() ], ]; - $request = new Request(['some_filter' => 'foo,bar,bar']); + $request = ['some_filter' => 'foo,bar,bar']; $this->assertEquals( [ 'Query parameter "some_filter" must contain less than 2 values', @@ -148,7 +147,7 @@ public function testSeparators() ); $filterDefinition['swagger']['collectionFormat'] = 'ssv'; - $request = new Request(['some_filter' => 'foo bar bar']); + $request = ['some_filter' => 'foo bar bar']; $this->assertEquals( [ 'Query parameter "some_filter" must contain less than 2 values', @@ -158,7 +157,7 @@ public function testSeparators() ); $filterDefinition['swagger']['collectionFormat'] = 'tsv'; - $request = new Request(['some_filter' => 'foo\tbar\tbar']); + $request = ['some_filter' => 'foo\tbar\tbar']; $this->assertEquals( [ 'Query parameter "some_filter" must contain less than 2 values', @@ -168,7 +167,7 @@ public function testSeparators() ); $filterDefinition['swagger']['collectionFormat'] = 'pipes'; - $request = new Request(['some_filter' => 'foo|bar|bar']); + $request = ['some_filter' => 'foo|bar|bar']; $this->assertEquals( [ 'Query parameter "some_filter" must contain less than 2 values', @@ -189,7 +188,7 @@ public function testSeparatorsUnknownSeparator() 'collectionFormat' => 'unknownFormat', ], ]; - $request = new Request(['some_filter' => 'foo,bar,bar']); + $request = ['some_filter' => 'foo,bar,bar']; $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('Unknown collection format unknownFormat'); diff --git a/tests/Filter/Validator/BoundsTest.php b/tests/Filter/Validator/BoundsTest.php index d993777a911..50f2958ec43 100644 --- a/tests/Filter/Validator/BoundsTest.php +++ b/tests/Filter/Validator/BoundsTest.php @@ -15,7 +15,6 @@ use ApiPlatform\Core\Filter\Validator\Bounds; use PHPUnit\Framework\TestCase; -use Symfony\Component\HttpFoundation\Request; /** * @author Julien Deniau @@ -24,27 +23,25 @@ class BoundsTest extends TestCase { public function testNonDefinedFilter() { - $request = new Request(); $filter = new Bounds(); $this->assertEmpty( - $filter->validate('some_filter', [], $request) + $filter->validate('some_filter', [], []) ); } public function testEmptyQueryParameter() { - $request = new Request(['some_filter' => '']); $filter = new Bounds(); $this->assertEmpty( - $filter->validate('some_filter', [], $request) + $filter->validate('some_filter', [], ['some_filter' => '']) ); } public function testNonMatchingMinimum() { - $request = new Request(['some_filter' => '9']); + $request = ['some_filter' => '9']; $filter = new Bounds(); $filterDefinition = [ @@ -85,7 +82,7 @@ public function testNonMatchingMinimum() public function testMatchingMinimum() { - $request = new Request(['some_filter' => '10']); + $request = ['some_filter' => '10']; $filter = new Bounds(); $filterDefinition = [ @@ -112,7 +109,7 @@ public function testMatchingMinimum() public function testNonMatchingMaximum() { - $request = new Request(['some_filter' => '11']); + $request = ['some_filter' => '11']; $filter = new Bounds(); $filterDefinition = [ @@ -153,7 +150,7 @@ public function testNonMatchingMaximum() public function testMatchingMaximum() { - $request = new Request(['some_filter' => '10']); + $request = ['some_filter' => '10']; $filter = new Bounds(); $filterDefinition = [ diff --git a/tests/Filter/Validator/EnumTest.php b/tests/Filter/Validator/EnumTest.php index e55697e4376..bd55f076a65 100644 --- a/tests/Filter/Validator/EnumTest.php +++ b/tests/Filter/Validator/EnumTest.php @@ -15,7 +15,6 @@ use ApiPlatform\Core\Filter\Validator\Enum; use PHPUnit\Framework\TestCase; -use Symfony\Component\HttpFoundation\Request; /** * @author Julien Deniau @@ -24,27 +23,24 @@ class EnumTest extends TestCase { public function testNonDefinedFilter() { - $request = new Request(); $filter = new Enum(); $this->assertEmpty( - $filter->validate('some_filter', [], $request) + $filter->validate('some_filter', [], []) ); } public function testEmptyQueryParameter() { - $request = new Request(['some_filter' => '']); $filter = new Enum(); $this->assertEmpty( - $filter->validate('some_filter', [], $request) + $filter->validate('some_filter', [], ['some_filter' => '']) ); } public function testNonMatchingParameter() { - $request = new Request(['some_filter' => 'foobar']); $filter = new Enum(); $filterDefinition = [ @@ -55,13 +51,12 @@ public function testNonMatchingParameter() $this->assertEquals( ['Query parameter "some_filter" must be one of "foo, bar"'], - $filter->validate('some_filter', $filterDefinition, $request) + $filter->validate('some_filter', $filterDefinition, ['some_filter' => 'foobar']) ); } public function testMatchingParameter() { - $request = new Request(['some_filter' => 'foo']); $filter = new Enum(); $filterDefinition = [ @@ -71,7 +66,7 @@ public function testMatchingParameter() ]; $this->assertEmpty( - $filter->validate('some_filter', $filterDefinition, $request) + $filter->validate('some_filter', $filterDefinition, ['some_filter' => 'foo']) ); } } diff --git a/tests/Filter/Validator/LengthTest.php b/tests/Filter/Validator/LengthTest.php index 76e60645af2..d9f36b3500c 100644 --- a/tests/Filter/Validator/LengthTest.php +++ b/tests/Filter/Validator/LengthTest.php @@ -15,7 +15,6 @@ use ApiPlatform\Core\Filter\Validator\Length; use PHPUnit\Framework\TestCase; -use Symfony\Component\HttpFoundation\Request; /** * @author Julien Deniau @@ -24,21 +23,19 @@ class LengthTest extends TestCase { public function testNonDefinedFilter() { - $request = new Request(); $filter = new Length(); $this->assertEmpty( - $filter->validate('some_filter', [], $request) + $filter->validate('some_filter', [], []) ); } public function testEmptyQueryParameter() { - $request = new Request(['some_filter' => '']); $filter = new Length(); $this->assertEmpty( - $filter->validate('some_filter', [], $request) + $filter->validate('some_filter', [], ['some_filter' => '']) ); } @@ -55,12 +52,12 @@ public function testNonMatchingParameter() $this->assertEquals( ['Query parameter "some_filter" length must be greater than or equal to 3'], - $filter->validate('some_filter', $filterDefinition, new Request(['some_filter' => 'ab'])) + $filter->validate('some_filter', $filterDefinition, ['some_filter' => 'ab']) ); $this->assertEquals( ['Query parameter "some_filter" length must be lower than or equal to 5'], - $filter->validate('some_filter', $filterDefinition, new Request(['some_filter' => 'abcdef'])) + $filter->validate('some_filter', $filterDefinition, ['some_filter' => 'abcdef']) ); } @@ -76,7 +73,7 @@ public function testNonMatchingParameterWithOnlyOneDefinition() $this->assertEquals( ['Query parameter "some_filter" length must be greater than or equal to 3'], - $filter->validate('some_filter', $filterDefinition, new Request(['some_filter' => 'ab'])) + $filter->validate('some_filter', $filterDefinition, ['some_filter' => 'ab']) ); $filterDefinition = [ @@ -87,7 +84,7 @@ public function testNonMatchingParameterWithOnlyOneDefinition() $this->assertEquals( ['Query parameter "some_filter" length must be lower than or equal to 5'], - $filter->validate('some_filter', $filterDefinition, new Request(['some_filter' => 'abcdef'])) + $filter->validate('some_filter', $filterDefinition, ['some_filter' => 'abcdef']) ); } @@ -103,15 +100,15 @@ public function testMatchingParameter() ]; $this->assertEmpty( - $filter->validate('some_filter', $filterDefinition, new Request(['some_filter' => 'abc'])) + $filter->validate('some_filter', $filterDefinition, ['some_filter' => 'abc']) ); $this->assertEmpty( - $filter->validate('some_filter', $filterDefinition, new Request(['some_filter' => 'abcd'])) + $filter->validate('some_filter', $filterDefinition, ['some_filter' => 'abcd']) ); $this->assertEmpty( - $filter->validate('some_filter', $filterDefinition, new Request(['some_filter' => 'abcde'])) + $filter->validate('some_filter', $filterDefinition, ['some_filter' => 'abcde']) ); } @@ -126,7 +123,7 @@ public function testMatchingParameterWithOneDefinition() ]; $this->assertEmpty( - $filter->validate('some_filter', $filterDefinition, new Request(['some_filter' => 'abc'])) + $filter->validate('some_filter', $filterDefinition, ['some_filter' => 'abc']) ); $filterDefinition = [ @@ -136,7 +133,7 @@ public function testMatchingParameterWithOneDefinition() ]; $this->assertEmpty( - $filter->validate('some_filter', $filterDefinition, new Request(['some_filter' => 'abcde'])) + $filter->validate('some_filter', $filterDefinition, ['some_filter' => 'abcde']) ); } } diff --git a/tests/Filter/Validator/MultipleOfTest.php b/tests/Filter/Validator/MultipleOfTest.php index a90386db765..f313cd3cba2 100644 --- a/tests/Filter/Validator/MultipleOfTest.php +++ b/tests/Filter/Validator/MultipleOfTest.php @@ -15,7 +15,6 @@ use ApiPlatform\Core\Filter\Validator\MultipleOf; use PHPUnit\Framework\TestCase; -use Symfony\Component\HttpFoundation\Request; /** * @author Julien Deniau @@ -24,17 +23,16 @@ class MultipleOfTest extends TestCase { public function testNonDefinedFilter() { - $request = new Request(); $filter = new MultipleOf(); $this->assertEmpty( - $filter->validate('some_filter', [], $request) + $filter->validate('some_filter', [], []) ); } public function testEmptyQueryParameter() { - $request = new Request(['some_filter' => '']); + $request = ['some_filter' => '']; $filter = new MultipleOf(); $this->assertEmpty( @@ -44,7 +42,7 @@ public function testEmptyQueryParameter() public function testNonMatchingParameter() { - $request = new Request(['some_filter' => '8']); + $request = ['some_filter' => '8']; $filter = new MultipleOf(); $filterDefinition = [ @@ -61,7 +59,7 @@ public function testNonMatchingParameter() public function testMatchingParameter() { - $request = new Request(['some_filter' => '8']); + $request = ['some_filter' => '8']; $filter = new MultipleOf(); $filterDefinition = [ diff --git a/tests/Filter/Validator/PatternTest.php b/tests/Filter/Validator/PatternTest.php index 02a924a8f5b..18f58aff86c 100644 --- a/tests/Filter/Validator/PatternTest.php +++ b/tests/Filter/Validator/PatternTest.php @@ -15,7 +15,6 @@ use ApiPlatform\Core\Filter\Validator\Pattern; use PHPUnit\Framework\TestCase; -use Symfony\Component\HttpFoundation\Request; /** * @author Julien Deniau @@ -24,11 +23,10 @@ class PatternTest extends TestCase { public function testNonDefinedFilter() { - $request = new Request(); $filter = new Pattern(); $this->assertEmpty( - $filter->validate('some_filter', [], $request) + $filter->validate('some_filter', [], []) ); } @@ -43,13 +41,13 @@ public function testFilterWithEmptyValue() ]; $this->assertEmpty( - $filter->validate('some_filter', $explicitFilterDefinition, new Request(['some_filter' => ''])) + $filter->validate('some_filter', $explicitFilterDefinition, ['some_filter' => '']) ); $weirdParameter = new \stdClass(); $weirdParameter->foo = 'non string value should not exists'; $this->assertEmpty( - $filter->validate('some_filter', $explicitFilterDefinition, new Request(['some_filter' => $weirdParameter])) + $filter->validate('some_filter', $explicitFilterDefinition, ['some_filter' => $weirdParameter]) ); } @@ -65,7 +63,7 @@ public function testFilterWithZeroAsParameter() $this->assertEquals( ['Query parameter "some_filter" must match pattern /foo/'], - $filter->validate('some_filter', $explicitFilterDefinition, new Request(['some_filter' => '0'])) + $filter->validate('some_filter', $explicitFilterDefinition, ['some_filter' => '0']) ); } @@ -81,7 +79,7 @@ public function testFilterWithNonMatchingValue() $this->assertEquals( ['Query parameter "some_filter" must match pattern /foo/'], - $filter->validate('some_filter', $explicitFilterDefinition, new Request(['some_filter' => 'bar'])) + $filter->validate('some_filter', $explicitFilterDefinition, ['some_filter' => 'bar']) ); } @@ -96,7 +94,7 @@ public function testFilterWithNonchingValue() ]; $this->assertEmpty( - $filter->validate('some_filter', $explicitFilterDefinition, new Request(['some_filter' => 'this is a foo '.random_int(0, 10).' and it should match'])) + $filter->validate('some_filter', $explicitFilterDefinition, ['some_filter' => 'this is a foo '.random_int(0, 10).' and it should match']) ); } } diff --git a/tests/Filter/Validator/RequiredTest.php b/tests/Filter/Validator/RequiredTest.php index b2c1596c144..fa7cd89ed97 100644 --- a/tests/Filter/Validator/RequiredTest.php +++ b/tests/Filter/Validator/RequiredTest.php @@ -15,7 +15,6 @@ use ApiPlatform\Core\Filter\Validator\Required; use PHPUnit\Framework\TestCase; -use Symfony\Component\HttpFoundation\Request; /** * Class RequiredTest. @@ -26,11 +25,11 @@ class RequiredTest extends TestCase { public function testNonRequiredFilter() { - $request = new Request(); + $request = []; $filter = new Required(); $this->assertEmpty( - $filter->validate('some_filter', [], $request) + $filter->validate('some_filter', [], []) ); $this->assertEmpty( @@ -40,7 +39,7 @@ public function testNonRequiredFilter() public function testRequiredFilterNotInQuery() { - $request = new Request(); + $request = []; $filter = new Required(); $this->assertEquals( @@ -51,7 +50,7 @@ public function testRequiredFilterNotInQuery() public function testRequiredFilterIsPresent() { - $request = new Request(['some_filter' => 'some_value']); + $request = ['some_filter' => 'some_value']; $filter = new Required(); $this->assertEmpty( @@ -61,7 +60,7 @@ public function testRequiredFilterIsPresent() public function testEmptyValueNotAllowed() { - $request = new Request(['some_filter' => '']); + $request = ['some_filter' => '']; $filter = new Required(); $explicitFilterDefinition = [ @@ -88,7 +87,7 @@ public function testEmptyValueNotAllowed() public function testEmptyValueAllowed() { - $request = new Request(['some_filter' => '']); + $request = ['some_filter' => '']; $filter = new Required(); $explicitFilterDefinition = [ From 167f8061cd99f678e1d8450bbd55c39a919308b1 Mon Sep 17 00:00:00 2001 From: Julien Deniau Date: Mon, 28 Jan 2019 15:05:55 +0100 Subject: [PATCH 041/160] use internal RequestParser instead of $request->query->all() --- .../QueryParameterValidateListener.php | 5 ++++- src/Filter/QueryParameterValidator.php | 6 +++--- src/Filter/Validator/Required.php | 4 ++-- .../QueryParameterValidateListenerTest.php | 13 ++++++++----- tests/Filter/QueryParameterValidatorTest.php | 11 ++++------- 5 files changed, 21 insertions(+), 18 deletions(-) diff --git a/src/EventListener/QueryParameterValidateListener.php b/src/EventListener/QueryParameterValidateListener.php index 14990387485..2d2ca127cc2 100644 --- a/src/EventListener/QueryParameterValidateListener.php +++ b/src/EventListener/QueryParameterValidateListener.php @@ -16,6 +16,7 @@ use ApiPlatform\Core\Filter\QueryParameterValidator; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use ApiPlatform\Core\Util\RequestAttributesExtractor; +use ApiPlatform\Core\Util\RequestParser; use Symfony\Component\HttpKernel\Event\RequestEvent; /** @@ -46,10 +47,12 @@ public function onKernelRequest(RequestEvent $event) ) { return; } + $queryString = RequestParser::getQueryString($request); + $queryParameters = $queryString ? RequestParser::parseRequestParams($queryString) : []; $resourceMetadata = $this->resourceMetadataFactory->create($attributes['resource_class']); $resourceFilters = $resourceMetadata->getCollectionOperationAttribute($operationName, 'filters', [], true); - $this->queryParameterValidator->validateFilters($attributes['resource_class'], $resourceFilters, $request); + $this->queryParameterValidator->validateFilters($attributes['resource_class'], $resourceFilters, $queryParameters); } } diff --git a/src/Filter/QueryParameterValidator.php b/src/Filter/QueryParameterValidator.php index 2aac20e1e2b..5a85a22333a 100644 --- a/src/Filter/QueryParameterValidator.php +++ b/src/Filter/QueryParameterValidator.php @@ -16,7 +16,6 @@ use ApiPlatform\Core\Api\FilterLocatorTrait; use ApiPlatform\Core\Exception\FilterValidationException; use Psr\Container\ContainerInterface; -use Symfony\Component\HttpFoundation\Request; /** * Validates query parameters depending on filter description. @@ -44,9 +43,10 @@ public function __construct(ContainerInterface $filterLocator) ]; } - public function validateFilters(string $resourceClass, array $resourceFilters, Request $request): void + public function validateFilters(string $resourceClass, array $resourceFilters, array $queryParameters): void { $errorList = []; + foreach ($resourceFilters as $filterId) { if (!$filter = $this->getFilter($filterId)) { continue; @@ -54,7 +54,7 @@ public function validateFilters(string $resourceClass, array $resourceFilters, R foreach ($filter->getDescription($resourceClass) as $name => $data) { foreach ($this->validators as $validator) { - $errorList = array_merge($errorList, $validator->validate($name, $data, $request->query->all())); + $errorList = array_merge($errorList, $validator->validate($name, $data, $queryParameters)); } } } diff --git a/src/Filter/Validator/Required.php b/src/Filter/Validator/Required.php index b018f59e4b9..4c5fb2d92a8 100644 --- a/src/Filter/Validator/Required.php +++ b/src/Filter/Validator/Required.php @@ -61,7 +61,7 @@ private function requestHasQueryParameter(array $queryParameters, string $name): if (\is_array($matches[$rootName])) { $keyName = array_keys($matches[$rootName])[0]; - $queryParameter = $queryParameters[(string) $rootName]; + $queryParameter = $queryParameters[(string) $rootName] ?? null; return \is_array($queryParameter) && isset($queryParameter[$keyName]); } @@ -90,7 +90,7 @@ private function requestGetQueryParameter(array $queryParameters, string $name) if (\is_array($matches[$rootName])) { $keyName = array_keys($matches[$rootName])[0]; - $queryParameter = $queryParameters[(string) $rootName]; + $queryParameter = $queryParameters[(string) $rootName] ?? null; if (\is_array($queryParameter) && isset($queryParameter[$keyName])) { return $queryParameter[$keyName]; diff --git a/tests/EventListener/QueryParameterValidateListenerTest.php b/tests/EventListener/QueryParameterValidateListenerTest.php index 2c52067f715..8a0b53f8361 100644 --- a/tests/EventListener/QueryParameterValidateListenerTest.php +++ b/tests/EventListener/QueryParameterValidateListenerTest.php @@ -59,7 +59,7 @@ public function testOnKernelRequestWithWrongFilter() $eventProphecy = $this->prophesize(RequestEvent::class); $eventProphecy->getRequest()->willReturn($request)->shouldBeCalled(); - $this->queryParameterValidor->validateFilters(Dummy::class, ['some_inexistent_filter'], $request)->shouldBeCalled(); + $this->queryParameterValidor->validateFilters(Dummy::class, ['some_inexistent_filter'], [])->shouldBeCalled(); $this->assertNull( $this->testedInstance->onKernelRequest($eventProphecy->reveal()) @@ -80,7 +80,7 @@ public function testOnKernelRequestWithRequiredFilterNotSet() $eventProphecy->getRequest()->willReturn($request)->shouldBeCalled(); $this->queryParameterValidor - ->validateFilters(Dummy::class, ['some_filter'], $request) + ->validateFilters(Dummy::class, ['some_filter'], []) ->shouldBeCalled() ->willThrow(new FilterValidationException(['Query parameter "required" is required'])); $this->expectException(FilterValidationException::class); @@ -96,9 +96,12 @@ public function testOnKernelRequestWithRequiredFilter() $this->setUpWithFilters(['some_filter']); $request = new Request( - ['required' => 'foo'], [], - ['_api_resource_class' => Dummy::class, '_api_collection_operation_name' => 'get'] + [], + ['_api_resource_class' => Dummy::class, '_api_collection_operation_name' => 'get'], + [], + [], + ['QUERY_STRING' => 'required=foo'] ); $request->setMethod('GET'); @@ -106,7 +109,7 @@ public function testOnKernelRequestWithRequiredFilter() $eventProphecy->getRequest()->willReturn($request)->shouldBeCalled(); $this->queryParameterValidor - ->validateFilters(Dummy::class, ['some_filter'], $request) + ->validateFilters(Dummy::class, ['some_filter'], ['required' => 'foo']) ->shouldBeCalled(); $this->assertNull( diff --git a/tests/Filter/QueryParameterValidatorTest.php b/tests/Filter/QueryParameterValidatorTest.php index c0f781407a2..1c9e60f06f6 100644 --- a/tests/Filter/QueryParameterValidatorTest.php +++ b/tests/Filter/QueryParameterValidatorTest.php @@ -19,7 +19,6 @@ use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy; use PHPUnit\Framework\TestCase; use Psr\Container\ContainerInterface; -use Symfony\Component\HttpFoundation\Request; /** * Class QueryParameterValidatorTest. @@ -48,7 +47,7 @@ protected function setUp(): void */ public function testOnKernelRequestWithUnsafeMethod() { - $request = new Request(); + $request = []; $this->assertNull( $this->testedInstance->validateFilters(Dummy::class, [], $request) @@ -60,7 +59,7 @@ public function testOnKernelRequestWithUnsafeMethod() */ public function testOnKernelRequestWithWrongFilter() { - $request = new Request(); + $request = []; $this->assertNull( $this->testedInstance->validateFilters(Dummy::class, ['some_inexistent_filter'], $request) @@ -72,7 +71,7 @@ public function testOnKernelRequestWithWrongFilter() */ public function testOnKernelRequestWithRequiredFilterNotSet() { - $request = new Request(); + $request = []; $filterProphecy = $this->prophesize(FilterInterface::class); $filterProphecy @@ -102,9 +101,7 @@ public function testOnKernelRequestWithRequiredFilterNotSet() */ public function testOnKernelRequestWithRequiredFilter() { - $request = new Request( - ['required' => 'foo'] - ); + $request = ['required' => 'foo']; $this->filterLocatorProphecy ->has('some_filter') From 3ec427e85ccaeff23aca620335bdd0b7c5da3ded Mon Sep 17 00:00:00 2001 From: Julien Deniau Date: Wed, 4 Dec 2019 23:13:15 +0100 Subject: [PATCH 042/160] Add support of collection get operations with different names ( thanks @TracKer ) --- src/EventListener/QueryParameterValidateListener.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/EventListener/QueryParameterValidateListener.php b/src/EventListener/QueryParameterValidateListener.php index 2d2ca127cc2..e9334c1583c 100644 --- a/src/EventListener/QueryParameterValidateListener.php +++ b/src/EventListener/QueryParameterValidateListener.php @@ -43,7 +43,8 @@ public function onKernelRequest(RequestEvent $event) !$request->isMethodSafe() || !($attributes = RequestAttributesExtractor::extractAttributes($request)) || !isset($attributes['collection_operation_name']) - || 'get' !== ($operationName = $attributes['collection_operation_name']) + || !($operationName = $attributes['collection_operation_name']) + || 'GET' !== $request->getMethod() ) { return; } From afa0995d67fbdbf2f66bda41f61ab639b1f71b77 Mon Sep 17 00:00:00 2001 From: jocelyn fournier Date: Wed, 11 Dec 2019 15:20:23 +0100 Subject: [PATCH 043/160] link a specific tag --- src/HttpCache/VarnishPurger.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/HttpCache/VarnishPurger.php b/src/HttpCache/VarnishPurger.php index 0811459d0ae..6b218dddd9d 100644 --- a/src/HttpCache/VarnishPurger.php +++ b/src/HttpCache/VarnishPurger.php @@ -42,7 +42,7 @@ public function __construct(array $clients, $maxHeaderLength = 7500) * * This assumes that the tags are separated by one character. * - * From https://github.com/FriendsOfSymfony/FOSHttpCache/blob/master/src/ProxyClient/HttpProxyClient.php#L137 + * From https://github.com/FriendsOfSymfony/FOSHttpCache/blob/2.8.0/src/ProxyClient/HttpProxyClient.php#L137 * * @param string[] $escapedTags * @param string $glue The concatenation string to use From e773104ab45c0693bdcb2872cc9bc25a26246922 Mon Sep 17 00:00:00 2001 From: Alan Poulain Date: Fri, 20 Dec 2019 10:18:30 +0100 Subject: [PATCH 044/160] [MongoDB] Mercure support (#3290) * Mercure for MongoDB * No abstract class * Add deprecated alias --- CHANGELOG.md | 1 + .../PublishMercureUpdatesListener.php | 94 ++++++++++--------- .../ApiPlatformExtension.php | 3 + ...doctrine_mongodb_odm_mercure_publisher.xml | 26 +++++ .../config/doctrine_orm_mercure_publisher.xml | 6 +- .../PublishMercureUpdatesListenerTest.php | 6 +- .../ApiPlatformExtensionTest.php | 11 ++- 7 files changed, 98 insertions(+), 49 deletions(-) create mode 100644 src/Bridge/Symfony/Bundle/Resources/config/doctrine_mongodb_odm_mercure_publisher.xml diff --git a/CHANGELOG.md b/CHANGELOG.md index af023711589..0679abd3abe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## 2.6.x-dev * MongoDB: Possibility to add execute options (aggregate command fields) for a resource, like `allowDiskUse` (#3144) +* MongoDB: Mercure support (#3290) * GraphQL: Allow to format GraphQL errors based on exceptions (#3063) * GraphQL: Add page-based pagination (#3175) * OpenAPI: Add PHP default values to the documentation (#2386) diff --git a/src/Bridge/Doctrine/EventListener/PublishMercureUpdatesListener.php b/src/Bridge/Doctrine/EventListener/PublishMercureUpdatesListener.php index d59051fd446..78f5bc50be8 100644 --- a/src/Bridge/Doctrine/EventListener/PublishMercureUpdatesListener.php +++ b/src/Bridge/Doctrine/EventListener/PublishMercureUpdatesListener.php @@ -21,7 +21,9 @@ use ApiPlatform\Core\Exception\RuntimeException; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use ApiPlatform\Core\Util\ResourceClassInfoTrait; -use Doctrine\ORM\Event\OnFlushEventArgs; +use Doctrine\Common\EventArgs; +use Doctrine\ODM\MongoDB\Event\OnFlushEventArgs as MongoDbOdmOnFlushEventArgs; +use Doctrine\ORM\Event\OnFlushEventArgs as OrmOnFlushEventArgs; use Symfony\Component\ExpressionLanguage\ExpressionLanguage; use Symfony\Component\Mercure\Update; use Symfony\Component\Messenger\MessageBusInterface; @@ -40,13 +42,12 @@ final class PublishMercureUpdatesListener use ResourceClassInfoTrait; private $iriConverter; - private $resourceMetadataFactory; private $serializer; private $publisher; private $expressionLanguage; - private $createdEntities; - private $updatedEntities; - private $deletedEntities; + private $createdObjects; + private $updatedObjects; + private $deletedObjects; private $formats; /** @@ -70,22 +71,31 @@ public function __construct(ResourceClassResolverInterface $resourceClassResolve } /** - * Collects created, updated and deleted entities. + * Collects created, updated and deleted objects. */ - public function onFlush(OnFlushEventArgs $eventArgs): void + public function onFlush(EventArgs $eventArgs): void { - $uow = $eventArgs->getEntityManager()->getUnitOfWork(); + if ($eventArgs instanceof OrmOnFlushEventArgs) { + $uow = $eventArgs->getEntityManager()->getUnitOfWork(); + } elseif ($eventArgs instanceof MongoDbOdmOnFlushEventArgs) { + $uow = $eventArgs->getDocumentManager()->getUnitOfWork(); + } else { + return; + } - foreach ($uow->getScheduledEntityInsertions() as $entity) { - $this->storeEntityToPublish($entity, 'createdEntities'); + $methodName = $eventArgs instanceof OrmOnFlushEventArgs ? 'getScheduledEntityInsertions' : 'getScheduledDocumentInsertions'; + foreach ($uow->{$methodName}() as $object) { + $this->storeObjectToPublish($object, 'createdObjects'); } - foreach ($uow->getScheduledEntityUpdates() as $entity) { - $this->storeEntityToPublish($entity, 'updatedEntities'); + $methodName = $eventArgs instanceof OrmOnFlushEventArgs ? 'getScheduledEntityUpdates' : 'getScheduledDocumentUpdates'; + foreach ($uow->{$methodName}() as $object) { + $this->storeObjectToPublish($object, 'updatedObjects'); } - foreach ($uow->getScheduledEntityDeletions() as $entity) { - $this->storeEntityToPublish($entity, 'deletedEntities'); + $methodName = $eventArgs instanceof OrmOnFlushEventArgs ? 'getScheduledEntityDeletions' : 'getScheduledDocumentDeletions'; + foreach ($uow->{$methodName}() as $object) { + $this->storeObjectToPublish($object, 'deletedObjects'); } } @@ -95,16 +105,16 @@ public function onFlush(OnFlushEventArgs $eventArgs): void public function postFlush(): void { try { - foreach ($this->createdEntities as $entity) { - $this->publishUpdate($entity, $this->createdEntities[$entity]); + foreach ($this->createdObjects as $object) { + $this->publishUpdate($object, $this->createdObjects[$object]); } - foreach ($this->updatedEntities as $entity) { - $this->publishUpdate($entity, $this->updatedEntities[$entity]); + foreach ($this->updatedObjects as $object) { + $this->publishUpdate($object, $this->updatedObjects[$object]); } - foreach ($this->deletedEntities as $entity) { - $this->publishUpdate($entity, $this->deletedEntities[$entity]); + foreach ($this->deletedObjects as $object) { + $this->publishUpdate($object, $this->deletedObjects[$object]); } } finally { $this->reset(); @@ -113,17 +123,17 @@ public function postFlush(): void private function reset(): void { - $this->createdEntities = new \SplObjectStorage(); - $this->updatedEntities = new \SplObjectStorage(); - $this->deletedEntities = new \SplObjectStorage(); + $this->createdObjects = new \SplObjectStorage(); + $this->updatedObjects = new \SplObjectStorage(); + $this->deletedObjects = new \SplObjectStorage(); } /** - * @param object $entity + * @param object $object */ - private function storeEntityToPublish($entity, string $property): void + private function storeObjectToPublish($object, string $property): void { - if (null === $resourceClass = $this->getResourceClass($entity)) { + if (null === $resourceClass = $this->getResourceClass($object)) { return; } @@ -137,7 +147,7 @@ private function storeEntityToPublish($entity, string $property): void throw new RuntimeException('The Expression Language component is not installed. Try running "composer require symfony/expression-language".'); } - $value = $this->expressionLanguage->evaluate($value, ['object' => $entity]); + $value = $this->expressionLanguage->evaluate($value, ['object' => $object]); } if (true === $value) { @@ -148,36 +158,36 @@ private function storeEntityToPublish($entity, string $property): void throw new InvalidArgumentException(sprintf('The value of the "mercure" attribute of the "%s" resource class must be a boolean, an array of targets or a valid expression, "%s" given.', $resourceClass, \gettype($value))); } - if ('deletedEntities' === $property) { - $this->deletedEntities[(object) [ - 'id' => $this->iriConverter->getIriFromItem($entity), - 'iri' => $this->iriConverter->getIriFromItem($entity, UrlGeneratorInterface::ABS_URL), + if ('deletedObjects' === $property) { + $this->deletedObjects[(object) [ + 'id' => $this->iriConverter->getIriFromItem($object), + 'iri' => $this->iriConverter->getIriFromItem($object, UrlGeneratorInterface::ABS_URL), ]] = $value; return; } - $this->{$property}[$entity] = $value; + $this->{$property}[$object] = $value; } /** - * @param object $entity + * @param object $object */ - private function publishUpdate($entity, array $targets): void + private function publishUpdate($object, array $targets): void { - if ($entity instanceof \stdClass) { - // By convention, if the entity has been deleted, we send only its IRI + if ($object instanceof \stdClass) { + // By convention, if the object has been deleted, we send only its IRI. // This may change in the feature, because it's not JSON Merge Patch compliant, - // and I'm not a fond of this approach - $iri = $entity->iri; + // and I'm not a fond of this approach. + $iri = $object->iri; /** @var string $data */ - $data = json_encode(['@id' => $entity->id]); + $data = json_encode(['@id' => $object->id]); } else { - $resourceClass = $this->getObjectClass($entity); + $resourceClass = $this->getObjectClass($object); $context = $this->resourceMetadataFactory->create($resourceClass)->getAttribute('normalization_context', []); - $iri = $this->iriConverter->getIriFromItem($entity, UrlGeneratorInterface::ABS_URL); - $data = $this->serializer->serialize($entity, key($this->formats), $context); + $iri = $this->iriConverter->getIriFromItem($object, UrlGeneratorInterface::ABS_URL); + $data = $this->serializer->serialize($object, key($this->formats), $context); } $update = new Update($iri, $data, $targets); diff --git a/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index fadf912ccc4..2ca6d227b23 100644 --- a/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -587,6 +587,9 @@ private function registerMercureConfiguration(ContainerBuilder $container, array if ($this->isConfigEnabled($container, $config['doctrine'])) { $loader->load('doctrine_orm_mercure_publisher.xml'); } + if ($this->isConfigEnabled($container, $config['doctrine_mongodb_odm'])) { + $loader->load('doctrine_mongodb_odm_mercure_publisher.xml'); + } } private function registerMessengerConfiguration(ContainerBuilder $container, array $config, XmlFileLoader $loader): void diff --git a/src/Bridge/Symfony/Bundle/Resources/config/doctrine_mongodb_odm_mercure_publisher.xml b/src/Bridge/Symfony/Bundle/Resources/config/doctrine_mongodb_odm_mercure_publisher.xml new file mode 100644 index 00000000000..f789dc3eb72 --- /dev/null +++ b/src/Bridge/Symfony/Bundle/Resources/config/doctrine_mongodb_odm_mercure_publisher.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + %api_platform.formats% + + + + + + + + + + diff --git a/src/Bridge/Symfony/Bundle/Resources/config/doctrine_orm_mercure_publisher.xml b/src/Bridge/Symfony/Bundle/Resources/config/doctrine_orm_mercure_publisher.xml index 54f4f4d1b79..382b034f356 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/doctrine_orm_mercure_publisher.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/doctrine_orm_mercure_publisher.xml @@ -8,7 +8,7 @@ - + @@ -21,6 +21,10 @@ + + Using "%alias_id%" service is deprecated since API Platform 2.6. Use "api_platform.doctrine.orm.listener.mercure.publish" instead. + + diff --git a/tests/Bridge/Doctrine/EventListener/PublishMercureUpdatesListenerTest.php b/tests/Bridge/Doctrine/EventListener/PublishMercureUpdatesListenerTest.php index a4d38a1a79a..4899cdd3d56 100644 --- a/tests/Bridge/Doctrine/EventListener/PublishMercureUpdatesListenerTest.php +++ b/tests/Bridge/Doctrine/EventListener/PublishMercureUpdatesListenerTest.php @@ -37,7 +37,7 @@ */ class PublishMercureUpdatesListenerTest extends TestCase { - public function testPublishUpdate() + public function testPublishUpdate(): void { $toInsert = new Dummy(); $toInsert->setId(1); @@ -115,7 +115,7 @@ public function testPublishUpdate() $this->assertSame([[], [], [], ['foo', 'bar']], $targets); } - public function testNoPublisher() + public function testNoPublisher(): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('A message bus or a publisher must be provided.'); @@ -131,7 +131,7 @@ public function testNoPublisher() ); } - public function testInvalidMercureAttribute() + public function testInvalidMercureAttribute(): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('The value of the "mercure" attribute of the "ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy" resource class must be a boolean, an array of targets or a valid expression, "integer" given.'); diff --git a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php index 93c83f0429d..89bbcc5ab0a 100644 --- a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php +++ b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php @@ -596,7 +596,7 @@ private function runDisableDoctrineTests() $containerBuilderProphecy->setDefinition('api_platform.doctrine.orm.range_filter', Argument::type(Definition::class))->shouldNotBeCalled(); $containerBuilderProphecy->setDefinition('api_platform.doctrine.orm.search_filter', Argument::type(Definition::class))->shouldNotBeCalled(); $containerBuilderProphecy->setDefinition('api_platform.doctrine.orm.subresource_data_provider', Argument::type(Definition::class))->shouldNotBeCalled(); - $containerBuilderProphecy->setDefinition('api_platform.doctrine.listener.mercure.publish', Argument::type(Definition::class))->shouldNotBeCalled(); + $containerBuilderProphecy->setDefinition('api_platform.doctrine.orm.listener.mercure.publish', Argument::type(Definition::class))->shouldNotBeCalled(); $containerBuilderProphecy->setAlias(EagerLoadingExtension::class, 'api_platform.doctrine.orm.query_extension.eager_loading')->shouldNotBeCalled(); $containerBuilderProphecy->setAlias(FilterExtension::class, 'api_platform.doctrine.orm.query_extension.filter')->shouldNotBeCalled(); $containerBuilderProphecy->setAlias(FilterEagerLoadingExtension::class, 'api_platform.doctrine.orm.query_extension.filter_eager_loading')->shouldNotBeCalled(); @@ -609,6 +609,7 @@ private function runDisableDoctrineTests() $containerBuilderProphecy->setAlias(BooleanFilter::class, 'api_platform.doctrine.orm.boolean_filter')->shouldNotBeCalled(); $containerBuilderProphecy->setAlias(NumericFilter::class, 'api_platform.doctrine.orm.numeric_filter')->shouldNotBeCalled(); $containerBuilderProphecy->setAlias(ExistsFilter::class, 'api_platform.doctrine.orm.exists_filter')->shouldNotBeCalled(); + $containerBuilderProphecy->setAlias('api_platform.doctrine.listener.mercure.publish', 'api_platform.doctrine.orm.listener.mercure.publish')->shouldNotBeCalled(); $containerBuilder = $containerBuilderProphecy->reveal(); $config = self::DEFAULT_CONFIG; @@ -648,6 +649,7 @@ public function testDisableDoctrineMongoDbOdm() $containerBuilderProphecy->setDefinition('api_platform.doctrine_mongodb.odm.range_filter', Argument::type(Definition::class))->shouldNotBeCalled(); $containerBuilderProphecy->setDefinition('api_platform.doctrine_mongodb.odm.search_filter', Argument::type(Definition::class))->shouldNotBeCalled(); $containerBuilderProphecy->setDefinition('api_platform.doctrine_mongodb.odm.subresource_data_provider', Argument::type(Definition::class))->shouldNotBeCalled(); + $containerBuilderProphecy->setDefinition('api_platform.doctrine_mongodb.odm.listener.mercure.publish', Argument::type(Definition::class))->shouldNotBeCalled(); $containerBuilderProphecy->setAlias(MongoDbOdmFilterExtension::class, 'api_platform.doctrine_mongodb.odm.aggregation_extension.filter')->shouldNotBeCalled(); $containerBuilderProphecy->setAlias(MongoDbOdmOrderExtension::class, 'api_platform.doctrine_mongodb.odm.aggregation_extension.order')->shouldNotBeCalled(); $containerBuilderProphecy->setAlias(MongoDbOdmPaginationExtension::class, 'api_platform.doctrine_mongodb.odm.aggregation_extension.pagination')->shouldNotBeCalled(); @@ -1146,7 +1148,7 @@ private function getBaseContainerBuilderProphecy(array $doctrineIntegrationsToLo $definitions = [ 'api_platform.data_collector.request', 'api_platform.doctrine.listener.http_cache.purge', - 'api_platform.doctrine.listener.mercure.publish', + 'api_platform.doctrine.orm.listener.mercure.publish', 'api_platform.doctrine.orm.boolean_filter', 'api_platform.doctrine.orm.collection_data_provider', 'api_platform.doctrine.orm.data_persister', @@ -1242,6 +1244,7 @@ private function getBaseContainerBuilderProphecy(array $doctrineIntegrationsToLo if (\in_array('odm', $doctrineIntegrationsToLoad, true)) { $definitions = array_merge($definitions, [ + 'api_platform.doctrine_mongodb.odm.listener.mercure.publish', 'api_platform.doctrine_mongodb.odm.aggregation_extension.filter', 'api_platform.doctrine_mongodb.odm.aggregation_extension.order', 'api_platform.doctrine_mongodb.odm.aggregation_extension.pagination', @@ -1323,6 +1326,7 @@ private function getBaseContainerBuilderProphecy(array $doctrineIntegrationsToLo BooleanFilter::class => 'api_platform.doctrine.orm.boolean_filter', NumericFilter::class => 'api_platform.doctrine.orm.numeric_filter', ExistsFilter::class => 'api_platform.doctrine.orm.exists_filter', + 'api_platform.doctrine.listener.mercure.publish' => 'api_platform.doctrine.orm.listener.mercure.publish', GraphQlSerializerContextBuilderInterface::class => 'api_platform.graphql.serializer.context_builder', ]; @@ -1359,7 +1363,8 @@ private function getBaseContainerBuilderProphecy(array $doctrineIntegrationsToLo $definitionDummy = $this->prophesize(Definition::class); $containerBuilderProphecy->removeDefinition('api_platform.cache_warmer.cache_pool_clearer')->will(function () {}); $containerBuilderProphecy->getDefinition('api_platform.mercure.listener.response.add_link_header')->willReturn($definitionDummy); - $containerBuilderProphecy->getDefinition('api_platform.doctrine.listener.mercure.publish')->willReturn($definitionDummy); + $containerBuilderProphecy->getDefinition('api_platform.doctrine.orm.listener.mercure.publish')->willReturn($definitionDummy); + $containerBuilderProphecy->getDefinition('api_platform.doctrine_mongodb.odm.listener.mercure.publish')->willReturn($definitionDummy); return $containerBuilderProphecy; } From 9298c4502b56f3bff7ed91ad05ea71ca216b58cf Mon Sep 17 00:00:00 2001 From: Alan Poulain Date: Mon, 23 Dec 2019 21:23:39 +0100 Subject: [PATCH 045/160] [GraphQL] Fix getGraphQlPaginationType when not a resource (#3328) --- src/DataProvider/Pagination.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/DataProvider/Pagination.php b/src/DataProvider/Pagination.php index b91ea79bd0b..ba793483a84 100644 --- a/src/DataProvider/Pagination.php +++ b/src/DataProvider/Pagination.php @@ -203,7 +203,11 @@ public function getOptions(): array public function getGraphQlPaginationType(string $resourceClass, string $operationName): string { - $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass); + try { + $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass); + } catch (ResourceClassNotFoundException $e) { + return 'cursor'; + } return (string) $resourceMetadata->getGraphqlAttribute($operationName, 'paginationType', 'cursor', true); } From 67bab89b8d2c7419884c7db69fe21e99337e75df Mon Sep 17 00:00:00 2001 From: jocelyn fournier Date: Mon, 13 Jan 2020 14:55:32 +0100 Subject: [PATCH 046/160] post review fixes --- src/HttpCache/VarnishPurger.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/HttpCache/VarnishPurger.php b/src/HttpCache/VarnishPurger.php index 6b218dddd9d..594f05beeae 100644 --- a/src/HttpCache/VarnishPurger.php +++ b/src/HttpCache/VarnishPurger.php @@ -24,14 +24,14 @@ */ final class VarnishPurger implements PurgerInterface { - private $maxHeaderLength; private $clients; + private $maxHeaderLength; /** * @param ClientInterface[] $clients * @param int $maxHeaderLength */ - public function __construct(array $clients, $maxHeaderLength = 7500) + public function __construct(array $clients, int $maxHeaderLength = 7500) { $this->clients = $clients; $this->maxHeaderLength = $maxHeaderLength; From 7c20f50e3f2698507dbd657f74f8c29380683368 Mon Sep 17 00:00:00 2001 From: jocelyn fournier Date: Mon, 13 Jan 2020 15:01:44 +0100 Subject: [PATCH 047/160] cs-fixer --- src/HttpCache/VarnishPurger.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/HttpCache/VarnishPurger.php b/src/HttpCache/VarnishPurger.php index 594f05beeae..ec226f5102c 100644 --- a/src/HttpCache/VarnishPurger.php +++ b/src/HttpCache/VarnishPurger.php @@ -29,7 +29,6 @@ final class VarnishPurger implements PurgerInterface /** * @param ClientInterface[] $clients - * @param int $maxHeaderLength */ public function __construct(array $clients, int $maxHeaderLength = 7500) { From d6f2603fe80236b1c7bac2a92b49b337c8cc6973 Mon Sep 17 00:00:00 2001 From: Alan Poulain Date: Mon, 3 Feb 2020 16:59:14 +0100 Subject: [PATCH 048/160] [GraphQL] Mercure Subscription Support (#3321) * Subscription support * Use sha256 hash for generating subscription identifiers * Update CHANGELOG --- CHANGELOG.md | 1 + behat.yml.dist | 5 + features/bootstrap/DoctrineContext.php | 31 +++ features/bootstrap/MercureContext.php | 66 ++++++ features/graphql/subscription.feature | 224 ++++++++++++++++++ phpstan.neon.dist | 6 +- .../PublishMercureUpdatesListener.php | 49 +++- .../ApiPlatformExtension.php | 5 + ...doctrine_mongodb_odm_mercure_publisher.xml | 2 + .../config/doctrine_orm_mercure_publisher.xml | 2 + .../Bundle/Resources/config/graphql.xml | 25 ++ .../Resources/config/graphql_mercure.xml | 15 ++ .../Factory/CollectionResolverFactory.php | 2 +- .../Factory/ItemMutationResolverFactory.php | 2 +- .../Resolver/Factory/ItemResolverFactory.php | 2 +- .../ItemSubscriptionResolverFactory.php | 90 +++++++ src/GraphQl/Resolver/Stage/ReadStage.php | 17 +- src/GraphQl/Resolver/Stage/SerializeStage.php | 21 +- src/GraphQl/Resolver/Util/IdentifierTrait.php | 35 +++ src/GraphQl/Serializer/ItemNormalizer.php | 6 +- src/GraphQl/Serializer/ObjectNormalizer.php | 6 +- .../Serializer/SerializerContextBuilder.php | 18 +- .../MercureSubscriptionIriGenerator.php | 52 ++++ ...rcureSubscriptionIriGeneratorInterface.php | 28 +++ .../SubscriptionIdentifierGenerator.php | 31 +++ ...bscriptionIdentifierGeneratorInterface.php | 26 ++ .../Subscription/SubscriptionManager.php | 124 ++++++++++ .../SubscriptionManagerInterface.php | 28 +++ src/GraphQl/Type/FieldsBuilder.php | 76 ++++-- src/GraphQl/Type/FieldsBuilderInterface.php | 7 +- src/GraphQl/Type/SchemaBuilder.php | 12 + src/GraphQl/Type/TypeBuilder.php | 39 ++- src/GraphQl/Type/TypeBuilderInterface.php | 2 +- src/GraphQl/Type/TypeConverter.php | 8 +- src/GraphQl/Type/TypeConverterInterface.php | 2 +- src/Util/SortTrait.php | 35 +++ .../PublishMercureUpdatesListenerTest.php | 70 ++++++ .../ApiPlatformExtensionTest.php | 11 + tests/Fixtures/DummyMercurePublisher.php | 12 + .../TestBundle/Document/DummyMercure.php | 15 ++ .../TestBundle/Entity/DummyMercure.php | 18 +- .../TestBundle/GraphQl/Type/TypeConverter.php | 4 +- tests/Fixtures/app/config/config_common.yml | 2 + .../Factory/CollectionResolverFactoryTest.php | 6 +- .../ItemMutationResolverFactoryTest.php | 12 +- .../Factory/ItemResolverFactoryTest.php | 12 +- .../ItemSubscriptionResolverFactoryTest.php | 199 ++++++++++++++++ .../GraphQl/Resolver/Stage/ReadStageTest.php | 21 +- .../Resolver/Stage/SerializeStageTest.php | 21 +- .../Resolver/Util/IdentifierTraitTest.php | 53 +++++ .../GraphQl/Serializer/ItemNormalizerTest.php | 51 ++++ .../SerializerContextBuilderTest.php | 56 ++++- .../MercureSubscriptionIriGeneratorTest.php | 55 +++++ .../SubscriptionIdentifierGeneratorTest.php | 95 ++++++++ .../Subscription/SubscriptionManagerTest.php | 194 +++++++++++++++ tests/GraphQl/Type/FieldsBuilderTest.php | 143 +++++++---- tests/GraphQl/Type/SchemaBuilderTest.php | 36 ++- tests/GraphQl/Type/TypeBuilderTest.php | 124 ++++++++-- tests/GraphQl/Type/TypeConverterTest.php | 10 +- tests/Util/SortTraitTest.php | 57 +++++ 60 files changed, 2188 insertions(+), 189 deletions(-) create mode 100644 features/bootstrap/MercureContext.php create mode 100644 features/graphql/subscription.feature create mode 100644 src/Bridge/Symfony/Bundle/Resources/config/graphql_mercure.xml create mode 100644 src/GraphQl/Resolver/Factory/ItemSubscriptionResolverFactory.php create mode 100644 src/GraphQl/Resolver/Util/IdentifierTrait.php create mode 100644 src/GraphQl/Subscription/MercureSubscriptionIriGenerator.php create mode 100644 src/GraphQl/Subscription/MercureSubscriptionIriGeneratorInterface.php create mode 100644 src/GraphQl/Subscription/SubscriptionIdentifierGenerator.php create mode 100644 src/GraphQl/Subscription/SubscriptionIdentifierGeneratorInterface.php create mode 100644 src/GraphQl/Subscription/SubscriptionManager.php create mode 100644 src/GraphQl/Subscription/SubscriptionManagerInterface.php create mode 100644 src/Util/SortTrait.php create mode 100644 tests/GraphQl/Resolver/Factory/ItemSubscriptionResolverFactoryTest.php create mode 100644 tests/GraphQl/Resolver/Util/IdentifierTraitTest.php create mode 100644 tests/GraphQl/Subscription/MercureSubscriptionIriGeneratorTest.php create mode 100644 tests/GraphQl/Subscription/SubscriptionIdentifierGeneratorTest.php create mode 100644 tests/GraphQl/Subscription/SubscriptionManagerTest.php create mode 100644 tests/Util/SortTraitTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index aab8ebbfdff..fbbeca40277 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ * MongoDB: Possibility to add execute options (aggregate command fields) for a resource, like `allowDiskUse` (#3144) * MongoDB: Mercure support (#3290) +* GraphQL: Subscription support with Mercure (#3321) * GraphQL: Allow to format GraphQL errors based on exceptions (#3063) * GraphQL: Add page-based pagination (#3175) * OpenAPI: Add PHP default values to the documentation (#2386) diff --git a/behat.yml.dist b/behat.yml.dist index 06748b4272b..181bb8495fa 100644 --- a/behat.yml.dist +++ b/behat.yml.dist @@ -17,6 +17,7 @@ default: jsonApiSchemaFile: '%paths.base%/tests/Fixtures/JsonSchema/jsonapi.json' - 'JsonHalContext': schemaFile: '%paths.base%/tests/Fixtures/JsonHal/jsonhal.json' + - 'MercureContext' - 'Behat\MinkExtension\Context\MinkContext' - 'Behatch\Context\RestContext' filters: @@ -56,6 +57,7 @@ postgres: jsonApiSchemaFile: '%paths.base%/tests/Fixtures/JsonSchema/jsonapi.json' - 'JsonHalContext': schemaFile: '%paths.base%/tests/Fixtures/JsonHal/jsonhal.json' + - 'MercureContext' - 'Behat\MinkExtension\Context\MinkContext' - 'Behatch\Context\RestContext' filters: @@ -81,6 +83,7 @@ mongodb: jsonApiSchemaFile: '%paths.base%/tests/Fixtures/JsonSchema/jsonapi.json' - 'JsonHalContext': schemaFile: '%paths.base%/tests/Fixtures/JsonHal/jsonhal.json' + - 'MercureContext' - 'Behat\MinkExtension\Context\MinkContext' - 'Behatch\Context\RestContext' filters: @@ -124,6 +127,7 @@ default-coverage: jsonApiSchemaFile: '%paths.base%/tests/Fixtures/JsonSchema/jsonapi.json' - 'JsonHalContext': schemaFile: '%paths.base%/tests/Fixtures/JsonHal/jsonhal.json' + - 'MercureContext' - 'CoverageContext' - 'Behat\MinkExtension\Context\MinkContext' - 'Behatch\Context\RestContext' @@ -149,6 +153,7 @@ mongodb-coverage: jsonApiSchemaFile: '%paths.base%/tests/Fixtures/JsonSchema/jsonapi.json' - 'JsonHalContext': schemaFile: '%paths.base%/tests/Fixtures/JsonHal/jsonhal.json' + - 'MercureContext' - 'CoverageContext' - 'Behat\MinkExtension\Context\MinkContext' - 'Behatch\Context\RestContext' diff --git a/features/bootstrap/DoctrineContext.php b/features/bootstrap/DoctrineContext.php index 3e519762e76..6ae697eeb29 100644 --- a/features/bootstrap/DoctrineContext.php +++ b/features/bootstrap/DoctrineContext.php @@ -37,6 +37,7 @@ use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\DummyDtoNoOutput as DummyDtoNoOutputDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\DummyFriend as DummyFriendDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\DummyGroup as DummyGroupDocument; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\DummyMercure as DummyMercureDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\DummyOffer as DummyOfferDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\DummyProduct as DummyProductDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\DummyProperty as DummyPropertyDocument; @@ -93,6 +94,7 @@ use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyFriend; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyGroup; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyImmutableDate; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyMercure; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyOffer; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyProduct; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyProperty; @@ -1502,6 +1504,27 @@ public function thereAreConvertedOwnerObjects(int $nb) $this->manager->flush(); } + /** + * @Given there are :nb dummy mercure objects + */ + public function thereAreDummyMercureObjects(int $nb) + { + for ($i = 1; $i <= $nb; ++$i) { + $relatedDummy = $this->buildRelatedDummy(); + $relatedDummy->setName('RelatedDummy #'.$i); + + $dummyMercure = $this->buildDummyMercure(); + $dummyMercure->name = "Dummy Mercure #$i"; + $dummyMercure->description = 'Description'; + $dummyMercure->relatedDummy = $relatedDummy; + + $this->manager->persist($relatedDummy); + $this->manager->persist($dummyMercure); + } + + $this->manager->flush(); + } + private function isOrm(): bool { return null !== $this->schemaTool; @@ -1879,4 +1902,12 @@ private function buildConvertedRelated() { return $this->isOrm() ? new ConvertedRelated() : new ConvertedRelatedDocument(); } + + /** + * @return DummyMercure|DummyMercureDocument + */ + private function buildDummyMercure() + { + return $this->isOrm() ? new DummyMercure() : new DummyMercureDocument(); + } } diff --git a/features/bootstrap/MercureContext.php b/features/bootstrap/MercureContext.php new file mode 100644 index 00000000000..ace847fb7bd --- /dev/null +++ b/features/bootstrap/MercureContext.php @@ -0,0 +1,66 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +use ApiPlatform\Core\Tests\Fixtures\DummyMercurePublisher; +use Behat\Gherkin\Node\PyStringNode; +use Behat\Symfony2Extension\Context\KernelAwareContext; +use Symfony\Component\HttpKernel\KernelInterface; +use Symfony\Component\Mercure\Update; + +/** + * Context for Mercure. + * + * @author Alan Poulain + */ +final class MercureContext implements KernelAwareContext +{ + private $kernel; + + public function setKernel(KernelInterface $kernel): void + { + $this->kernel = $kernel; + } + + /** + * @Then the following Mercure update with topics :topics should have been sent: + */ + public function theFollowingMercureUpdateShouldHaveBeenSent(string $topics, PyStringNode $update): void + { + $topics = explode(',', $topics); + $update = json_decode($update->getRaw(), true); + /** @var DummyMercurePublisher $publisher */ + $publisher = $this->kernel->getContainer()->get('mercure.hub.default.publisher'); + + /** @var Update $sentUpdate */ + foreach ($publisher->getUpdates() as $sentUpdate) { + $toMatchTopics = count($topics); + foreach ($sentUpdate->getTopics() as $sentTopic) { + foreach ($topics as $topic) { + if (preg_match("@$topic@", $sentTopic)) { + --$toMatchTopics; + } + } + } + + if ($toMatchTopics > 0) { + continue; + } + + if ($sentUpdate->getData() === json_encode($update)) { + return; + } + } + + throw new \RuntimeException('Mercure update has not been sent.'); + } +} diff --git a/features/graphql/subscription.feature b/features/graphql/subscription.feature new file mode 100644 index 00000000000..123cb5b8e52 --- /dev/null +++ b/features/graphql/subscription.feature @@ -0,0 +1,224 @@ +Feature: GraphQL subscription support + + @createSchema + Scenario: Introspect subscription type + When I send the following GraphQL request: + """ + { + __type(name: "Subscription") { + fields { + name + description + type { + name + kind + } + args { + name + type { + name + kind + ofType { + name + kind + } + } + } + } + } + } + """ + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/json" + And the JSON should be valid according to this schema: + """ + { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "object", + "required": [ + "__type" + ], + "properties": { + "__type": { + "type": "object", + "required": [ + "fields" + ], + "properties": { + "fields": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": [ + "name", + "description", + "type", + "args" + ], + "properties": { + "name": { + "pattern": "^update[A-z0-9]+Subscribe" + }, + "description": { + "pattern": "^Subscribes to the update event of a [A-z0-9]+.$" + }, + "type": { + "type": "object", + "required": [ + "name", + "kind" + ], + "properties": { + "name": { + "pattern": "^update[A-z0-9]+SubscriptionPayload$" + }, + "kind": { + "enum": ["OBJECT"] + } + } + }, + "args": { + "type": "array", + "minItems": 1, + "maxItems": 1, + "items": [ + { + "type": "object", + "required": [ + "name", + "type" + ], + "properties": { + "name": { + "enum": ["input"] + }, + "type": { + "type": "object", + "required": [ + "kind", + "ofType" + ], + "properties": { + "kind": { + "enum": ["NON_NULL"] + }, + "ofType": { + "type": "object", + "required": [ + "name", + "kind" + ], + "properties": { + "name": { + "pattern": "^update[A-z0-9]+SubscriptionInput$" + }, + "kind": { + "enum": ["INPUT_OBJECT"] + } + } + } + } + } + } + } + ] + } + } + } + } + } + } + } + } + } + } + """ + + Scenario: Subscribe to updates + Given there are 2 dummy mercure objects + When I send the following GraphQL request: + """ + subscription { + updateDummyMercureSubscribe(input: {id: "/dummy_mercures/1", clientSubscriptionId: "myId"}) { + dummyMercure { + id + name + relatedDummy { + name + } + } + mercureUrl + clientSubscriptionId + } + } + """ + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/json" + And the JSON node "data.updateDummyMercureSubscribe.dummyMercure.id" should be equal to "/dummy_mercures/1" + And the JSON node "data.updateDummyMercureSubscribe.dummyMercure.name" should be equal to "Dummy Mercure #1" + And the JSON node "data.updateDummyMercureSubscribe.mercureUrl" should match "@^https://demo.mercure.rocks/hub\?topic=http://example.com/subscriptions/[a-f0-9]+$@" + And the JSON node "data.updateDummyMercureSubscribe.clientSubscriptionId" should be equal to "myId" + + When I send the following GraphQL request: + """ + subscription { + updateDummyMercureSubscribe(input: {id: "/dummy_mercures/2"}) { + dummyMercure { + id + } + mercureUrl + } + } + """ + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/json" + And the JSON node "data.updateDummyMercureSubscribe.dummyMercure.id" should be equal to "/dummy_mercures/2" + And the JSON node "data.updateDummyMercureSubscribe.mercureUrl" should match "@^https://demo.mercure.rocks/hub\?topic=http://example.com/subscriptions/[a-f0-9]+$@" + + Scenario: Receive Mercure updates with different payloads from subscriptions + When I add "Accept" header equal to "application/ld+json" + And I add "Content-Type" header equal to "application/ld+json" + And I send a "PUT" request to "/dummy_mercures/1" with body: + """ + { + "name": "Dummy Mercure #1 updated" + } + """ + Then the following Mercure update with topics "http://example.com/subscriptions/[a-f0-9]+" should have been sent: + """ + { + "dummyMercure": { + "id": 1, + "name": "Dummy Mercure #1 updated", + "relatedDummy": { + "name": "RelatedDummy #1" + } + } + } + """ + + When I add "Accept" header equal to "application/ld+json" + And I add "Content-Type" header equal to "application/ld+json" + And I send a "PUT" request to "/dummy_mercures/2" with body: + """ + { + "name": "Dummy Mercure #2 updated" + } + """ + Then the following Mercure update with topics "http://example.com/subscriptions/[a-f0-9]+" should have been sent: + """ + { + "dummyMercure": { + "id": 2 + } + } + """ diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 8a9d7d13838..b755ee10afb 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -31,9 +31,6 @@ parameters: - message: '#Variable \$positionPm might not be defined\.#' path: %currentWorkingDirectory%/src/Util/ClassInfoTrait.php - - - message: '#Cannot assign offset .+ to bool\.#' - path: %currentWorkingDirectory%/src/GraphQl/Serializer/SerializerContextBuilder.php - '#Access to an undefined property Prophecy\\Prophecy\\ObjectProphecy<(\\?[a-zA-Z0-9_]+)+>::\$[a-zA-Z0-9_]+#' - message: '#Call to an undefined method Doctrine\\Persistence\\ObjectManager::getConnection\(\)#' @@ -93,6 +90,9 @@ parameters: - message: '#Service "api_platform.iri_converter" is private\.#' path: %currentWorkingDirectory%/src/Bridge/Symfony/Bundle/Test/ApiTestCase.php + - + message: "#Call to method PHPUnit\\\\Framework\\\\Assert::assertSame\\(\\) with array\\(.+\\) and array\\(.+\\) will always evaluate to false\\.#" + path: %currentWorkingDirectory%/tests/Util/SortTraitTest.php # Expected, due to optional interfaces - '#Method ApiPlatform\\Core\\Bridge\\Doctrine\\Orm\\Extension\\QueryCollectionExtensionInterface::applyToCollection\(\) invoked with 5 parameters, 3-4 required\.#' diff --git a/src/Bridge/Doctrine/EventListener/PublishMercureUpdatesListener.php b/src/Bridge/Doctrine/EventListener/PublishMercureUpdatesListener.php index 78f5bc50be8..572722d5a70 100644 --- a/src/Bridge/Doctrine/EventListener/PublishMercureUpdatesListener.php +++ b/src/Bridge/Doctrine/EventListener/PublishMercureUpdatesListener.php @@ -19,12 +19,15 @@ use ApiPlatform\Core\Bridge\Symfony\Messenger\DispatchTrait; use ApiPlatform\Core\Exception\InvalidArgumentException; use ApiPlatform\Core\Exception\RuntimeException; +use ApiPlatform\Core\GraphQl\Subscription\MercureSubscriptionIriGeneratorInterface as GraphQlMercureSubscriptionIriGeneratorInterface; +use ApiPlatform\Core\GraphQl\Subscription\SubscriptionManagerInterface as GraphQlSubscriptionManagerInterface; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use ApiPlatform\Core\Util\ResourceClassInfoTrait; use Doctrine\Common\EventArgs; use Doctrine\ODM\MongoDB\Event\OnFlushEventArgs as MongoDbOdmOnFlushEventArgs; use Doctrine\ORM\Event\OnFlushEventArgs as OrmOnFlushEventArgs; use Symfony\Component\ExpressionLanguage\ExpressionLanguage; +use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\Mercure\Update; use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\Serializer\SerializerInterface; @@ -49,11 +52,13 @@ final class PublishMercureUpdatesListener private $updatedObjects; private $deletedObjects; private $formats; + private $graphQlSubscriptionManager; + private $graphQlMercureSubscriptionIriGenerator; /** * @param array $formats */ - public function __construct(ResourceClassResolverInterface $resourceClassResolver, IriConverterInterface $iriConverter, ResourceMetadataFactoryInterface $resourceMetadataFactory, SerializerInterface $serializer, array $formats, MessageBusInterface $messageBus = null, callable $publisher = null, ExpressionLanguage $expressionLanguage = null) + public function __construct(ResourceClassResolverInterface $resourceClassResolver, IriConverterInterface $iriConverter, ResourceMetadataFactoryInterface $resourceMetadataFactory, SerializerInterface $serializer, array $formats, MessageBusInterface $messageBus = null, callable $publisher = null, ?GraphQlSubscriptionManagerInterface $graphQlSubscriptionManager = null, ?GraphQlMercureSubscriptionIriGeneratorInterface $graphQlMercureSubscriptionIriGenerator = null, ExpressionLanguage $expressionLanguage = null) { if (null === $messageBus && null === $publisher) { throw new InvalidArgumentException('A message bus or a publisher must be provided.'); @@ -67,6 +72,8 @@ public function __construct(ResourceClassResolverInterface $resourceClassResolve $this->messageBus = $messageBus; $this->publisher = $publisher; $this->expressionLanguage = $expressionLanguage ?? class_exists(ExpressionLanguage::class) ? new ExpressionLanguage() : null; + $this->graphQlSubscriptionManager = $graphQlSubscriptionManager; + $this->graphQlMercureSubscriptionIriGenerator = $graphQlMercureSubscriptionIriGenerator; $this->reset(); } @@ -106,15 +113,15 @@ public function postFlush(): void { try { foreach ($this->createdObjects as $object) { - $this->publishUpdate($object, $this->createdObjects[$object]); + $this->publishUpdate($object, $this->createdObjects[$object], 'create'); } foreach ($this->updatedObjects as $object) { - $this->publishUpdate($object, $this->updatedObjects[$object]); + $this->publishUpdate($object, $this->updatedObjects[$object], 'update'); } foreach ($this->deletedObjects as $object) { - $this->publishUpdate($object, $this->deletedObjects[$object]); + $this->publishUpdate($object, $this->deletedObjects[$object], 'delete'); } } finally { $this->reset(); @@ -173,7 +180,7 @@ private function storeObjectToPublish($object, string $property): void /** * @param object $object */ - private function publishUpdate($object, array $targets): void + private function publishUpdate($object, array $targets, string $type): void { if ($object instanceof \stdClass) { // By convention, if the object has been deleted, we send only its IRI. @@ -190,7 +197,35 @@ private function publishUpdate($object, array $targets): void $data = $this->serializer->serialize($object, key($this->formats), $context); } - $update = new Update($iri, $data, $targets); - $this->messageBus ? $this->dispatch($update) : ($this->publisher)($update); + $updates = array_merge([new Update($iri, $data, $targets)], $this->getGraphQlSubscriptionUpdates($object, $targets, $type)); + + foreach ($updates as $update) { + $this->messageBus ? $this->dispatch($update) : ($this->publisher)($update); + } + } + + /** + * @param object $object + * + * @return Update[] + */ + private function getGraphQlSubscriptionUpdates($object, array $targets, string $type): array + { + if ('update' !== $type || !$this->graphQlSubscriptionManager || !$this->graphQlMercureSubscriptionIriGenerator) { + return []; + } + + $payloads = $this->graphQlSubscriptionManager->getPushPayloads($object); + + $updates = []; + foreach ($payloads as [$subscriptionId, $data]) { + $updates[] = new Update( + $this->graphQlMercureSubscriptionIriGenerator->generateTopicIri($subscriptionId), + (string) (new JsonResponse($data))->getContent(), + $targets + ); + } + + return $updates; } } diff --git a/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index 2ca6d227b23..de9a2031c03 100644 --- a/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -590,6 +590,11 @@ private function registerMercureConfiguration(ContainerBuilder $container, array if ($this->isConfigEnabled($container, $config['doctrine_mongodb_odm'])) { $loader->load('doctrine_mongodb_odm_mercure_publisher.xml'); } + + if ($this->isConfigEnabled($container, $config['graphql'])) { + $loader->load('graphql_mercure.xml'); + $container->getDefinition('api_platform.graphql.subscription.mercure_iri_generator')->addArgument($config['mercure']['hub_url'] ?? '%mercure.default_hub%'); + } } private function registerMessengerConfiguration(ContainerBuilder $container, array $config, XmlFileLoader $loader): void diff --git a/src/Bridge/Symfony/Bundle/Resources/config/doctrine_mongodb_odm_mercure_publisher.xml b/src/Bridge/Symfony/Bundle/Resources/config/doctrine_mongodb_odm_mercure_publisher.xml index f789dc3eb72..8d7c9672b12 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/doctrine_mongodb_odm_mercure_publisher.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/doctrine_mongodb_odm_mercure_publisher.xml @@ -16,6 +16,8 @@ %api_platform.formats% + + diff --git a/src/Bridge/Symfony/Bundle/Resources/config/doctrine_orm_mercure_publisher.xml b/src/Bridge/Symfony/Bundle/Resources/config/doctrine_orm_mercure_publisher.xml index 382b034f356..6868de419b7 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/doctrine_orm_mercure_publisher.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/doctrine_orm_mercure_publisher.xml @@ -16,6 +16,8 @@ %api_platform.formats% + + diff --git a/src/Bridge/Symfony/Bundle/Resources/config/graphql.xml b/src/Bridge/Symfony/Bundle/Resources/config/graphql.xml index ef5cc6d0590..a5ca2c709ea 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/graphql.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/graphql.xml @@ -40,6 +40,15 @@ + + + + + + + + + @@ -140,6 +149,7 @@ + @@ -241,6 +251,21 @@ + + + + + + + + + + + + + + + diff --git a/src/Bridge/Symfony/Bundle/Resources/config/graphql_mercure.xml b/src/Bridge/Symfony/Bundle/Resources/config/graphql_mercure.xml new file mode 100644 index 00000000000..8307be7cb87 --- /dev/null +++ b/src/Bridge/Symfony/Bundle/Resources/config/graphql_mercure.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + diff --git a/src/GraphQl/Resolver/Factory/CollectionResolverFactory.php b/src/GraphQl/Resolver/Factory/CollectionResolverFactory.php index af846ce2c0f..8fff34f5643 100644 --- a/src/GraphQl/Resolver/Factory/CollectionResolverFactory.php +++ b/src/GraphQl/Resolver/Factory/CollectionResolverFactory.php @@ -71,7 +71,7 @@ public function __invoke(?string $resourceClass = null, ?string $rootClass = nul } $operationName = $operationName ?? 'collection_query'; - $resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => true, 'is_mutation' => false]; + $resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => true, 'is_mutation' => false, 'is_subscription' => false]; $collection = ($this->readStage)($resourceClass, $rootClass, $operationName, $resolverContext); if (!is_iterable($collection)) { diff --git a/src/GraphQl/Resolver/Factory/ItemMutationResolverFactory.php b/src/GraphQl/Resolver/Factory/ItemMutationResolverFactory.php index 5214bfa7232..45fc233f00a 100644 --- a/src/GraphQl/Resolver/Factory/ItemMutationResolverFactory.php +++ b/src/GraphQl/Resolver/Factory/ItemMutationResolverFactory.php @@ -70,7 +70,7 @@ public function __invoke(?string $resourceClass = null, ?string $rootClass = nul return null; } - $resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => false, 'is_mutation' => true]; + $resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => false, 'is_mutation' => true, 'is_subscription' => false]; $item = ($this->readStage)($resourceClass, $rootClass, $operationName, $resolverContext); if (null !== $item && !\is_object($item)) { diff --git a/src/GraphQl/Resolver/Factory/ItemResolverFactory.php b/src/GraphQl/Resolver/Factory/ItemResolverFactory.php index d7ab7387666..41be00912f2 100644 --- a/src/GraphQl/Resolver/Factory/ItemResolverFactory.php +++ b/src/GraphQl/Resolver/Factory/ItemResolverFactory.php @@ -64,7 +64,7 @@ public function __invoke(?string $resourceClass = null, ?string $rootClass = nul } $operationName = $operationName ?? 'item_query'; - $resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => false, 'is_mutation' => false]; + $resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => false, 'is_mutation' => false, 'is_subscription' => false]; $item = ($this->readStage)($resourceClass, $rootClass, $operationName, $resolverContext); if (null !== $item && !\is_object($item)) { diff --git a/src/GraphQl/Resolver/Factory/ItemSubscriptionResolverFactory.php b/src/GraphQl/Resolver/Factory/ItemSubscriptionResolverFactory.php new file mode 100644 index 00000000000..decad8398b3 --- /dev/null +++ b/src/GraphQl/Resolver/Factory/ItemSubscriptionResolverFactory.php @@ -0,0 +1,90 @@ + + * + * 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\GraphQl\Resolver\Factory; + +use ApiPlatform\Core\GraphQl\Resolver\Stage\ReadStageInterface; +use ApiPlatform\Core\GraphQl\Resolver\Stage\SecurityStageInterface; +use ApiPlatform\Core\GraphQl\Resolver\Stage\SerializeStageInterface; +use ApiPlatform\Core\GraphQl\Subscription\MercureSubscriptionIriGeneratorInterface; +use ApiPlatform\Core\GraphQl\Subscription\SubscriptionManagerInterface; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\Util\ClassInfoTrait; +use ApiPlatform\Core\Util\CloneTrait; +use GraphQL\Type\Definition\ResolveInfo; + +/** + * Creates a function resolving a GraphQL subscription of an item. + * + * @experimental + * + * @author Alan Poulain + */ +final class ItemSubscriptionResolverFactory implements ResolverFactoryInterface +{ + use ClassInfoTrait; + use CloneTrait; + + private $readStage; + private $securityStage; + private $serializeStage; + private $resourceMetadataFactory; + private $subscriptionManager; + private $mercureSubscriptionIriGenerator; + + public function __construct(ReadStageInterface $readStage, SecurityStageInterface $securityStage, SerializeStageInterface $serializeStage, ResourceMetadataFactoryInterface $resourceMetadataFactory, SubscriptionManagerInterface $subscriptionManager, ?MercureSubscriptionIriGeneratorInterface $mercureSubscriptionIriGenerator) + { + $this->readStage = $readStage; + $this->securityStage = $securityStage; + $this->serializeStage = $serializeStage; + $this->resourceMetadataFactory = $resourceMetadataFactory; + $this->subscriptionManager = $subscriptionManager; + $this->mercureSubscriptionIriGenerator = $mercureSubscriptionIriGenerator; + } + + public function __invoke(?string $resourceClass = null, ?string $rootClass = null, ?string $operationName = null): callable + { + return function (?array $source, array $args, $context, ResolveInfo $info) use ($resourceClass, $rootClass, $operationName) { + if (null === $resourceClass || null === $operationName) { + return null; + } + + $resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => false, 'is_mutation' => false, 'is_subscription' => true]; + + $item = ($this->readStage)($resourceClass, $rootClass, $operationName, $resolverContext); + if (null !== $item && !\is_object($item)) { + throw new \LogicException('Item from read stage should be a nullable object.'); + } + ($this->securityStage)($resourceClass, $operationName, $resolverContext + [ + 'extra_variables' => [ + 'object' => $item, + ], + ]); + + $result = ($this->serializeStage)($item, $resourceClass, $operationName, $resolverContext); + + $subscriptionId = $this->subscriptionManager->retrieveSubscriptionId($resolverContext, $result); + + $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass); + + if ($subscriptionId && $resourceMetadata->getAttribute('mercure', false)) { + if (!$this->mercureSubscriptionIriGenerator) { + throw new \LogicException('Cannot use Mercure for subscriptions when MercureBundle is not installed. Try running "composer require mercure".'); + } + $result['mercureUrl'] = $this->mercureSubscriptionIriGenerator->generateMercureUrl($subscriptionId); + } + + return $result; + }; + } +} diff --git a/src/GraphQl/Resolver/Stage/ReadStage.php b/src/GraphQl/Resolver/Stage/ReadStage.php index 3f16806e16b..f74e4657b10 100644 --- a/src/GraphQl/Resolver/Stage/ReadStage.php +++ b/src/GraphQl/Resolver/Stage/ReadStage.php @@ -17,6 +17,7 @@ use ApiPlatform\Core\DataProvider\ContextAwareCollectionDataProviderInterface; use ApiPlatform\Core\DataProvider\SubresourceDataProviderInterface; use ApiPlatform\Core\Exception\ItemNotFoundException; +use ApiPlatform\Core\GraphQl\Resolver\Util\IdentifierTrait; use ApiPlatform\Core\GraphQl\Serializer\ItemNormalizer; use ApiPlatform\Core\GraphQl\Serializer\SerializerContextBuilderInterface; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; @@ -34,6 +35,7 @@ final class ReadStage implements ReadStageInterface { use ClassInfoTrait; + use IdentifierTrait; private $resourceMetadataFactory; private $iriConverter; @@ -66,10 +68,10 @@ public function __invoke(?string $resourceClass, ?string $rootClass, string $ope $normalizationContext = $this->serializerContextBuilder->create($resourceClass, $operationName, $context, true); if (!$context['is_collection']) { - $identifier = $this->getIdentifier($context); + $identifier = $this->getIdentifierFromContext($context); $item = $this->getItem($identifier, $normalizationContext); - if ($identifier && $context['is_mutation']) { + if ($identifier && ($context['is_mutation'] || $context['is_subscription'])) { if (null === $item) { throw new NotFoundHttpException(sprintf('Item "%s" not found.', $args['input']['id'])); } @@ -104,17 +106,6 @@ public function __invoke(?string $resourceClass, ?string $rootClass, string $ope return $this->collectionDataProvider->getCollection($resourceClass, $operationName, $normalizationContext); } - private function getIdentifier(array $context): ?string - { - $args = $context['args']; - - if ($context['is_mutation']) { - return $args['input']['id'] ?? null; - } - - return $args['id'] ?? null; - } - /** * @return object|null */ diff --git a/src/GraphQl/Resolver/Stage/SerializeStage.php b/src/GraphQl/Resolver/Stage/SerializeStage.php index 42eb1d5f95a..e4fc54491fd 100644 --- a/src/GraphQl/Resolver/Stage/SerializeStage.php +++ b/src/GraphQl/Resolver/Stage/SerializeStage.php @@ -15,6 +15,7 @@ use ApiPlatform\Core\DataProvider\Pagination; use ApiPlatform\Core\DataProvider\PaginatorInterface; +use ApiPlatform\Core\GraphQl\Resolver\Util\IdentifierTrait; use ApiPlatform\Core\GraphQl\Serializer\ItemNormalizer; use ApiPlatform\Core\GraphQl\Serializer\SerializerContextBuilderInterface; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; @@ -29,6 +30,8 @@ */ final class SerializeStage implements SerializeStageInterface { + use IdentifierTrait; + private $resourceMetadataFactory; private $normalizer; private $serializerContextBuilder; @@ -49,6 +52,7 @@ public function __invoke($itemOrCollection, string $resourceClass, string $opera { $isCollection = $context['is_collection']; $isMutation = $context['is_mutation']; + $isSubscription = $context['is_subscription']; $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass); if (!$resourceMetadata->getGraphqlAttribute($operationName, 'serialize', true, true)) { @@ -66,17 +70,19 @@ public function __invoke($itemOrCollection, string $resourceClass, string $opera return $this->getDefaultMutationData($context); } + if ($isSubscription) { + return $this->getDefaultSubscriptionData($context); + } + return null; } $normalizationContext = $this->serializerContextBuilder->create($resourceClass, $operationName, $context, true); - $args = $context['args']; - $data = null; if (!$isCollection) { if ($isMutation && 'delete' === $operationName) { - $data = ['id' => $args['input']['id'] ?? null]; + $data = ['id' => $this->getIdentifierFromContext($context)]; } else { $data = $this->normalizer->normalize($itemOrCollection, ItemNormalizer::FORMAT, $normalizationContext); } @@ -99,10 +105,10 @@ public function __invoke($itemOrCollection, string $resourceClass, string $opera throw new \UnexpectedValueException('Expected serialized data to be a nullable array.'); } - if ($isMutation) { + if ($isMutation || $isSubscription) { $wrapFieldName = lcfirst($resourceMetadata->getShortName()); - return [$wrapFieldName => $data] + $this->getDefaultMutationData($context); + return [$wrapFieldName => $data] + ($isMutation ? $this->getDefaultMutationData($context) : $this->getDefaultSubscriptionData($context)); } return $data; @@ -201,4 +207,9 @@ private function getDefaultMutationData(array $context): array { return ['clientMutationId' => $context['args']['input']['clientMutationId'] ?? null]; } + + private function getDefaultSubscriptionData(array $context): array + { + return ['clientSubscriptionId' => $context['args']['input']['clientSubscriptionId'] ?? null]; + } } diff --git a/src/GraphQl/Resolver/Util/IdentifierTrait.php b/src/GraphQl/Resolver/Util/IdentifierTrait.php new file mode 100644 index 00000000000..0dcee001ffc --- /dev/null +++ b/src/GraphQl/Resolver/Util/IdentifierTrait.php @@ -0,0 +1,35 @@ + + * + * 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\GraphQl\Resolver\Util; + +/** + * Identifier helper methods. + * + * @internal + * + * @author Alan Poulain + */ +trait IdentifierTrait +{ + private function getIdentifierFromContext(array $context): ?string + { + $args = $context['args']; + + if ($context['is_mutation'] || $context['is_subscription']) { + return $args['input']['id'] ?? null; + } + + return $args['id'] ?? null; + } +} diff --git a/src/GraphQl/Serializer/ItemNormalizer.php b/src/GraphQl/Serializer/ItemNormalizer.php index 42c5d5a9490..e7c5e6c8a0c 100644 --- a/src/GraphQl/Serializer/ItemNormalizer.php +++ b/src/GraphQl/Serializer/ItemNormalizer.php @@ -76,8 +76,10 @@ public function normalize($object, $format = null, array $context = []) throw new UnexpectedValueException('Expected data to be an array.'); } - $data[self::ITEM_RESOURCE_CLASS_KEY] = $this->getObjectClass($object); - $data[self::ITEM_IDENTIFIERS_KEY] = $this->identifiersExtractor->getIdentifiersFromItem($object); + if (!($context['no_resolver_data'] ?? false)) { + $data[self::ITEM_RESOURCE_CLASS_KEY] = $this->getObjectClass($object); + $data[self::ITEM_IDENTIFIERS_KEY] = $this->identifiersExtractor->getIdentifiersFromItem($object); + } return $data; } diff --git a/src/GraphQl/Serializer/ObjectNormalizer.php b/src/GraphQl/Serializer/ObjectNormalizer.php index 56620d5e1d7..dc93a1dc40c 100644 --- a/src/GraphQl/Serializer/ObjectNormalizer.php +++ b/src/GraphQl/Serializer/ObjectNormalizer.php @@ -85,8 +85,10 @@ public function normalize($object, $format = null, array $context = []) $data['id'] = $this->iriConverter->getIriFromItem($originalResource); } - $data[self::ITEM_RESOURCE_CLASS_KEY] = $this->getObjectClass($originalResource); - $data[self::ITEM_IDENTIFIERS_KEY] = $this->identifiersExtractor->getIdentifiersFromItem($originalResource); + if (!($context['no_resolver_data'] ?? false)) { + $data[self::ITEM_RESOURCE_CLASS_KEY] = $this->getObjectClass($originalResource); + $data[self::ITEM_IDENTIFIERS_KEY] = $this->identifiersExtractor->getIdentifiersFromItem($originalResource); + } return $data; } diff --git a/src/GraphQl/Serializer/SerializerContextBuilder.php b/src/GraphQl/Serializer/SerializerContextBuilder.php index 2f56d5be72c..49803d82751 100644 --- a/src/GraphQl/Serializer/SerializerContextBuilder.php +++ b/src/GraphQl/Serializer/SerializerContextBuilder.php @@ -49,6 +49,10 @@ public function create(?string $resourceClass, string $operationName, array $res $context['attributes'] = $this->fieldsToAttributes($resourceMetadata, $resolverContext); } + if (isset($resolverContext['fields'])) { + $context['no_resolver_data'] = true; + } + if ($resourceMetadata) { $context['input'] = $resourceMetadata->getGraphqlAttribute($operationName, 'input', null, true); $context['output'] = $resourceMetadata->getGraphqlAttribute($operationName, 'output', null, true); @@ -65,15 +69,19 @@ public function create(?string $resourceClass, string $operationName, array $res */ private function fieldsToAttributes(?ResourceMetadata $resourceMetadata, array $context): array { - /** @var ResolveInfo $info */ - $info = $context['info']; - $fields = $info->getFieldSelection(PHP_INT_MAX); + if (isset($context['fields'])) { + $fields = $context['fields']; + } else { + /** @var ResolveInfo $info */ + $info = $context['info']; + $fields = $info->getFieldSelection(PHP_INT_MAX); + } $attributes = $this->replaceIdKeys($fields['edges']['node'] ?? $fields); - if ($context['is_mutation']) { + if ($context['is_mutation'] || $context['is_subscription']) { if (!$resourceMetadata) { - throw new \LogicException('ResourceMetadata should always exist for a mutation.'); + throw new \LogicException('ResourceMetadata should always exist for a mutation or a subscription.'); } $wrapFieldName = lcfirst($resourceMetadata->getShortName()); diff --git a/src/GraphQl/Subscription/MercureSubscriptionIriGenerator.php b/src/GraphQl/Subscription/MercureSubscriptionIriGenerator.php new file mode 100644 index 00000000000..80ca4e73ab8 --- /dev/null +++ b/src/GraphQl/Subscription/MercureSubscriptionIriGenerator.php @@ -0,0 +1,52 @@ + + * + * 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\GraphQl\Subscription; + +use Symfony\Component\Routing\RequestContext; + +/** + * Generates Mercure-related IRIs from a subscription ID. + * + * @experimental + * + * @author Alan Poulain + */ +final class MercureSubscriptionIriGenerator implements MercureSubscriptionIriGeneratorInterface +{ + private $requestContext; + private $hub; + + public function __construct(RequestContext $requestContext, string $hub) + { + $this->requestContext = $requestContext; + $this->hub = $hub; + } + + public function generateTopicIri(string $subscriptionId): string + { + if ('' === $scheme = $this->requestContext->getScheme()) { + $scheme = 'https'; + } + if ('' === $host = $this->requestContext->getHost()) { + $host = 'api-platform.com'; + } + + return "$scheme://$host/subscriptions/$subscriptionId"; + } + + public function generateMercureUrl(string $subscriptionId): string + { + return $this->hub.'?topic='.$this->generateTopicIri($subscriptionId); + } +} diff --git a/src/GraphQl/Subscription/MercureSubscriptionIriGeneratorInterface.php b/src/GraphQl/Subscription/MercureSubscriptionIriGeneratorInterface.php new file mode 100644 index 00000000000..605cc773701 --- /dev/null +++ b/src/GraphQl/Subscription/MercureSubscriptionIriGeneratorInterface.php @@ -0,0 +1,28 @@ + + * + * 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\GraphQl\Subscription; + +/** + * Generates Mercure-related IRIs from a subscription ID. + * + * @experimental + * + * @author Alan Poulain + */ +interface MercureSubscriptionIriGeneratorInterface +{ + public function generateTopicIri(string $subscriptionId): string; + + public function generateMercureUrl(string $subscriptionId): string; +} diff --git a/src/GraphQl/Subscription/SubscriptionIdentifierGenerator.php b/src/GraphQl/Subscription/SubscriptionIdentifierGenerator.php new file mode 100644 index 00000000000..b64786e45b5 --- /dev/null +++ b/src/GraphQl/Subscription/SubscriptionIdentifierGenerator.php @@ -0,0 +1,31 @@ + + * + * 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\GraphQl\Subscription; + +/** + * Generates an identifier used to identify a subscription. + * + * @experimental + * + * @author Alan Poulain + */ +final class SubscriptionIdentifierGenerator implements SubscriptionIdentifierGeneratorInterface +{ + public function generateSubscriptionIdentifier(array $fields): string + { + unset($fields['mercureUrl'], $fields['clientSubscriptionId']); + + return hash('sha256', print_r($fields, true)); + } +} diff --git a/src/GraphQl/Subscription/SubscriptionIdentifierGeneratorInterface.php b/src/GraphQl/Subscription/SubscriptionIdentifierGeneratorInterface.php new file mode 100644 index 00000000000..bcef927f80f --- /dev/null +++ b/src/GraphQl/Subscription/SubscriptionIdentifierGeneratorInterface.php @@ -0,0 +1,26 @@ + + * + * 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\GraphQl\Subscription; + +/** + * Generates an identifier used to identify a subscription. + * + * @experimental + * + * @author Alan Poulain + */ +interface SubscriptionIdentifierGeneratorInterface +{ + public function generateSubscriptionIdentifier(array $fields): string; +} diff --git a/src/GraphQl/Subscription/SubscriptionManager.php b/src/GraphQl/Subscription/SubscriptionManager.php new file mode 100644 index 00000000000..df2d43a5172 --- /dev/null +++ b/src/GraphQl/Subscription/SubscriptionManager.php @@ -0,0 +1,124 @@ + + * + * 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\GraphQl\Subscription; + +use ApiPlatform\Core\Api\IriConverterInterface; +use ApiPlatform\Core\GraphQl\Resolver\Stage\SerializeStageInterface; +use ApiPlatform\Core\GraphQl\Resolver\Util\IdentifierTrait; +use ApiPlatform\Core\Util\ResourceClassInfoTrait; +use ApiPlatform\Core\Util\SortTrait; +use GraphQL\Type\Definition\ResolveInfo; +use Psr\Cache\CacheItemPoolInterface; + +/** + * Manages all the queried subscriptions by creating their ID + * and saving to a cache the information needed to publish updated data. + * + * @experimental + * + * @author Alan Poulain + */ +final class SubscriptionManager implements SubscriptionManagerInterface +{ + use IdentifierTrait; + use ResourceClassInfoTrait; + use SortTrait; + + private $subscriptionsCache; + private $subscriptionIdentifierGenerator; + private $serializeStage; + private $iriConverter; + + public function __construct(CacheItemPoolInterface $subscriptionsCache, SubscriptionIdentifierGeneratorInterface $subscriptionIdentifierGenerator, SerializeStageInterface $serializeStage, IriConverterInterface $iriConverter) + { + $this->subscriptionsCache = $subscriptionsCache; + $this->subscriptionIdentifierGenerator = $subscriptionIdentifierGenerator; + $this->serializeStage = $serializeStage; + $this->iriConverter = $iriConverter; + } + + public function retrieveSubscriptionId(array $context, ?array $result): ?string + { + /** @var ResolveInfo $info */ + $info = $context['info']; + $fields = $info->getFieldSelection(PHP_INT_MAX); + $this->arrayRecursiveSort($fields, 'ksort'); + $iri = $this->getIdentifierFromContext($context); + if (null === $iri) { + return null; + } + $subscriptionsCacheItem = $this->subscriptionsCache->getItem($this->encodeIriToCacheKey($iri)); + $subscriptions = []; + if ($subscriptionsCacheItem->isHit()) { + $subscriptions = $subscriptionsCacheItem->get(); + foreach ($subscriptions as [$subscriptionId, $subscriptionFields, $subscriptionResult]) { + if ($subscriptionFields === $fields) { + return $subscriptionId; + } + } + } + + $subscriptionId = $this->subscriptionIdentifierGenerator->generateSubscriptionIdentifier($fields); + unset($result['clientSubscriptionId']); + $subscriptions[] = [$subscriptionId, $fields, $result]; + $subscriptionsCacheItem->set($subscriptions); + $this->subscriptionsCache->save($subscriptionsCacheItem); + + return $subscriptionId; + } + + /** + * @param object $object + */ + public function getPushPayloads($object): array + { + $iri = $this->iriConverter->getIriFromItem($object); + $subscriptions = $this->getSubscriptionsFromIri($iri); + + $resourceClass = $this->getObjectClass($object); + + $payloads = []; + foreach ($subscriptions as [$subscriptionId, $subscriptionFields, $subscriptionResult]) { + $resolverContext = ['fields' => $subscriptionFields, 'is_collection' => false, 'is_mutation' => false, 'is_subscription' => true]; + + $data = ($this->serializeStage)($object, $resourceClass, 'update', $resolverContext); + unset($data['clientSubscriptionId']); + + if ($data !== $subscriptionResult) { + $payloads[] = [$subscriptionId, $data]; + } + } + + return $payloads; + } + + /** + * @return array + */ + private function getSubscriptionsFromIri(string $iri): array + { + $subscriptionsCacheItem = $this->subscriptionsCache->getItem($this->encodeIriToCacheKey($iri)); + + if ($subscriptionsCacheItem->isHit()) { + return $subscriptionsCacheItem->get(); + } + + return []; + } + + private function encodeIriToCacheKey(string $iri): string + { + return str_replace('/', '_', $iri); + } +} diff --git a/src/GraphQl/Subscription/SubscriptionManagerInterface.php b/src/GraphQl/Subscription/SubscriptionManagerInterface.php new file mode 100644 index 00000000000..e745b754b51 --- /dev/null +++ b/src/GraphQl/Subscription/SubscriptionManagerInterface.php @@ -0,0 +1,28 @@ + + * + * 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\GraphQl\Subscription; + +/** + * Manages all the queried subscriptions and creates their ID. + * + * @experimental + * + * @author Alan Poulain + */ +interface SubscriptionManagerInterface +{ + public function retrieveSubscriptionId(array $context, ?array $result): ?string; + + public function getPushPayloads($object): array; +} diff --git a/src/GraphQl/Type/FieldsBuilder.php b/src/GraphQl/Type/FieldsBuilder.php index a18e24e654c..3842f00ae4d 100644 --- a/src/GraphQl/Type/FieldsBuilder.php +++ b/src/GraphQl/Type/FieldsBuilder.php @@ -49,12 +49,13 @@ final class FieldsBuilder implements FieldsBuilderInterface private $itemResolverFactory; private $collectionResolverFactory; private $itemMutationResolverFactory; + private $itemSubscriptionResolverFactory; private $filterLocator; private $pagination; private $nameConverter; private $nestingSeparator; - public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, ResourceMetadataFactoryInterface $resourceMetadataFactory, TypesContainerInterface $typesContainer, TypeBuilderInterface $typeBuilder, TypeConverterInterface $typeConverter, ResolverFactoryInterface $itemResolverFactory, ResolverFactoryInterface $collectionResolverFactory, ResolverFactoryInterface $itemMutationResolverFactory, ContainerInterface $filterLocator, Pagination $pagination, ?NameConverterInterface $nameConverter, string $nestingSeparator) + public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, ResourceMetadataFactoryInterface $resourceMetadataFactory, TypesContainerInterface $typesContainer, TypeBuilderInterface $typeBuilder, TypeConverterInterface $typeConverter, ResolverFactoryInterface $itemResolverFactory, ResolverFactoryInterface $collectionResolverFactory, ResolverFactoryInterface $itemMutationResolverFactory, ResolverFactoryInterface $itemSubscriptionResolverFactory, ContainerInterface $filterLocator, Pagination $pagination, ?NameConverterInterface $nameConverter, string $nestingSeparator) { $this->propertyNameCollectionFactory = $propertyNameCollectionFactory; $this->propertyMetadataFactory = $propertyMetadataFactory; @@ -65,6 +66,7 @@ public function __construct(PropertyNameCollectionFactoryInterface $propertyName $this->itemResolverFactory = $itemResolverFactory; $this->collectionResolverFactory = $collectionResolverFactory; $this->itemMutationResolverFactory = $itemMutationResolverFactory; + $this->itemSubscriptionResolverFactory = $itemSubscriptionResolverFactory; $this->filterLocator = $filterLocator; $this->pagination = $pagination; $this->nameConverter = $nameConverter; @@ -95,7 +97,7 @@ public function getItemQueryFields(string $resourceClass, ResourceMetadata $reso $deprecationReason = (string) $resourceMetadata->getGraphqlAttribute($queryName, 'deprecation_reason', '', true); - if ($fieldConfiguration = $this->getResourceFieldConfiguration(null, null, $deprecationReason, new Type(Type::BUILTIN_TYPE_OBJECT, true, $resourceClass), $resourceClass, false, $queryName, null)) { + if ($fieldConfiguration = $this->getResourceFieldConfiguration(null, null, $deprecationReason, new Type(Type::BUILTIN_TYPE_OBJECT, true, $resourceClass), $resourceClass, false, $queryName, null, null)) { $args = $this->resolveResourceArgs($configuration['args'] ?? [], $queryName, $shortName); $configuration['args'] = $args ?: $configuration['args'] ?? ['id' => ['type' => GraphQLType::nonNull(GraphQLType::id())]]; @@ -115,7 +117,7 @@ public function getCollectionQueryFields(string $resourceClass, ResourceMetadata $deprecationReason = (string) $resourceMetadata->getGraphqlAttribute($queryName, 'deprecation_reason', '', true); - if ($fieldConfiguration = $this->getResourceFieldConfiguration(null, null, $deprecationReason, new Type(Type::BUILTIN_TYPE_OBJECT, false, null, true, null, new Type(Type::BUILTIN_TYPE_OBJECT, false, $resourceClass)), $resourceClass, false, $queryName, null)) { + if ($fieldConfiguration = $this->getResourceFieldConfiguration(null, null, $deprecationReason, new Type(Type::BUILTIN_TYPE_OBJECT, false, null, true, null, new Type(Type::BUILTIN_TYPE_OBJECT, false, $resourceClass)), $resourceClass, false, $queryName, null, null)) { $args = $this->resolveResourceArgs($configuration['args'] ?? [], $queryName, $shortName); $configuration['args'] = $args ?: $configuration['args'] ?? $fieldConfiguration['args']; @@ -135,12 +137,8 @@ public function getMutationFields(string $resourceClass, ResourceMetadata $resou $resourceType = new Type(Type::BUILTIN_TYPE_OBJECT, true, $resourceClass); $deprecationReason = $resourceMetadata->getGraphqlAttribute($mutationName, 'deprecation_reason', '', true); - if ($fieldConfiguration = $this->getResourceFieldConfiguration(null, ucfirst("{$mutationName}s a $shortName."), $deprecationReason, $resourceType, $resourceClass, false, null, $mutationName)) { - $fieldConfiguration['args'] += ['input' => $this->getResourceFieldConfiguration(null, null, $deprecationReason, $resourceType, $resourceClass, true, null, $mutationName)]; - - if (!$this->typeBuilder->isCollection($resourceType)) { - $fieldConfiguration['resolve'] = ($this->itemMutationResolverFactory)($resourceClass, null, $mutationName); - } + if ($fieldConfiguration = $this->getResourceFieldConfiguration(null, ucfirst("{$mutationName}s a $shortName."), $deprecationReason, $resourceType, $resourceClass, false, null, $mutationName, null)) { + $fieldConfiguration['args'] += ['input' => $this->getResourceFieldConfiguration(null, null, $deprecationReason, $resourceType, $resourceClass, true, null, $mutationName, null)]; } $mutationFields[$mutationName.$resourceMetadata->getShortName()] = $fieldConfiguration ?? []; @@ -151,11 +149,35 @@ public function getMutationFields(string $resourceClass, ResourceMetadata $resou /** * {@inheritdoc} */ - public function getResourceObjectTypeFields(?string $resourceClass, ResourceMetadata $resourceMetadata, bool $input, ?string $queryName, ?string $mutationName, int $depth = 0, ?array $ioMetadata = null): array + public function getSubscriptionFields(string $resourceClass, ResourceMetadata $resourceMetadata, string $subscriptionName): array + { + $subscriptionFields = []; + $shortName = $resourceMetadata->getShortName(); + $resourceType = new Type(Type::BUILTIN_TYPE_OBJECT, true, $resourceClass); + $deprecationReason = $resourceMetadata->getGraphqlAttribute($subscriptionName, 'deprecation_reason', '', true); + + if ($fieldConfiguration = $this->getResourceFieldConfiguration(null, "Subscribes to the $subscriptionName event of a $shortName.", $deprecationReason, $resourceType, $resourceClass, false, null, null, $subscriptionName)) { + $fieldConfiguration['args'] += ['input' => $this->getResourceFieldConfiguration(null, null, $deprecationReason, $resourceType, $resourceClass, true, null, null, $subscriptionName)]; + } + + if (!$fieldConfiguration) { + return []; + } + + $subscriptionFields[$subscriptionName.$resourceMetadata->getShortName().'Subscribe'] = $fieldConfiguration; + + return $subscriptionFields; + } + + /** + * {@inheritdoc} + */ + public function getResourceObjectTypeFields(?string $resourceClass, ResourceMetadata $resourceMetadata, bool $input, ?string $queryName, ?string $mutationName, ?string $subscriptionName, int $depth = 0, ?array $ioMetadata = null): array { $fields = []; $idField = ['type' => GraphQLType::nonNull(GraphQLType::id())]; $clientMutationId = GraphQLType::string(); + $clientSubscriptionId = GraphQLType::string(); if (null !== $ioMetadata && \array_key_exists('class', $ioMetadata) && null === $ioMetadata['class']) { if ($input) { @@ -165,6 +187,13 @@ public function getResourceObjectTypeFields(?string $resourceClass, ResourceMeta return []; } + if (null !== $subscriptionName && $input) { + return [ + 'id' => $idField, + 'clientSubscriptionId' => $clientSubscriptionId, + ]; + } + if ('delete' === $mutationName) { $fields = [ 'id' => $idField, @@ -185,7 +214,7 @@ public function getResourceObjectTypeFields(?string $resourceClass, ResourceMeta if (null !== $resourceClass) { foreach ($this->propertyNameCollectionFactory->create($resourceClass) as $property) { - $propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $property, ['graphql_operation_name' => $mutationName ?? $queryName]); + $propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $property, ['graphql_operation_name' => $subscriptionName ?? $mutationName ?? $queryName]); if ( null === ($propertyType = $propertyMetadata->getType()) || (!$input && false === $propertyMetadata->isReadable()) @@ -194,7 +223,7 @@ public function getResourceObjectTypeFields(?string $resourceClass, ResourceMeta continue; } - if ($fieldConfiguration = $this->getResourceFieldConfiguration($property, $propertyMetadata->getDescription(), $propertyMetadata->getAttribute('deprecation_reason', ''), $propertyType, $resourceClass, $input, $queryName, $mutationName, $depth)) { + if ($fieldConfiguration = $this->getResourceFieldConfiguration($property, $propertyMetadata->getDescription(), $propertyMetadata->getAttribute('deprecation_reason', ''), $propertyType, $resourceClass, $input, $queryName, $mutationName, $subscriptionName, $depth)) { $fields['id' === $property ? '_id' : $this->normalizePropertyName($property)] = $fieldConfiguration; } } @@ -228,12 +257,12 @@ public function resolveResourceArgs(array $args, string $operationName, string $ * * @see http://webonyx.github.io/graphql-php/type-system/object-types/ */ - private function getResourceFieldConfiguration(?string $property, ?string $fieldDescription, string $deprecationReason, Type $type, string $rootResource, bool $input, ?string $queryName, ?string $mutationName, int $depth = 0): ?array + private function getResourceFieldConfiguration(?string $property, ?string $fieldDescription, string $deprecationReason, Type $type, string $rootResource, bool $input, ?string $queryName, ?string $mutationName, ?string $subscriptionName, int $depth = 0): ?array { try { $resourceClass = $this->typeBuilder->isCollection($type) && ($collectionValueType = $type->getCollectionValueType()) ? $collectionValueType->getClassName() : $type->getClassName(); - if (null === $graphqlType = $this->convertType($type, $input, $queryName, $mutationName, $resourceClass ?? '', $rootResource, $property, $depth)) { + if (null === $graphqlType = $this->convertType($type, $input, $queryName, $mutationName, $subscriptionName, $resourceClass ?? '', $rootResource, $property, $depth)) { return null; } @@ -251,8 +280,13 @@ private function getResourceFieldConfiguration(?string $property, ?string $field } } + // Check mercure attribute if it's a subscription at the root level. + if ($subscriptionName && null === $property && (!$resourceMetadata || !$resourceMetadata->getAttribute('mercure', false))) { + return null; + } + $args = []; - if (!$input && null === $mutationName && !$isStandardGraphqlType && $this->typeBuilder->isCollection($type)) { + if (!$input && null === $mutationName && null === $subscriptionName && !$isStandardGraphqlType && $this->typeBuilder->isCollection($type)) { if ($this->pagination->isGraphQlEnabled($resourceClass, $queryName)) { $args = $this->getGraphQlPaginationArgs($resourceClass, $queryName); } @@ -262,6 +296,10 @@ private function getResourceFieldConfiguration(?string $property, ?string $field if ($isStandardGraphqlType || $input) { $resolve = null; + } elseif ($mutationName) { + $resolve = ($this->itemMutationResolverFactory)($resourceClass, $rootResource, $mutationName); + } elseif ($subscriptionName) { + $resolve = ($this->itemSubscriptionResolverFactory)($resourceClass, $rootResource, $subscriptionName); } elseif ($this->typeBuilder->isCollection($type)) { $resolve = ($this->collectionResolverFactory)($resourceClass, $rootResource, $queryName); } else { @@ -340,7 +378,7 @@ private function getFilterArgs(array $args, ?string $resourceClass, ?ResourceMet foreach ($this->filterLocator->get($filterId)->getDescription($resourceClass) as $key => $value) { $nullable = isset($value['required']) ? !$value['required'] : true; $filterType = \in_array($value['type'], Type::$builtinTypes, true) ? new Type($value['type'], $nullable) : new Type('object', $nullable, $value['type']); - $graphqlFilterType = $this->convertType($filterType, false, $queryName, $mutationName, $resourceClass, $rootResource, $property, $depth); + $graphqlFilterType = $this->convertType($filterType, false, $queryName, $mutationName, null, $resourceClass, $rootResource, $property, $depth); if ('[]' === substr($key, -2)) { $graphqlFilterType = GraphQLType::listOf($graphqlFilterType); @@ -428,9 +466,9 @@ private function convertFilterArgsToTypes(array $args): array * * @throws InvalidTypeException */ - private function convertType(Type $type, bool $input, ?string $queryName, ?string $mutationName, string $resourceClass, string $rootResource, ?string $property, int $depth) + private function convertType(Type $type, bool $input, ?string $queryName, ?string $mutationName, ?string $subscriptionName, string $resourceClass, string $rootResource, ?string $property, int $depth) { - $graphqlType = $this->typeConverter->convertType($type, $input, $queryName, $mutationName, $resourceClass, $rootResource, $property, $depth); + $graphqlType = $this->typeConverter->convertType($type, $input, $queryName, $mutationName, $subscriptionName, $resourceClass, $rootResource, $property, $depth); if (null === $graphqlType) { throw new InvalidTypeException(sprintf('The type "%s" is not supported.', $type->getBuiltinType())); @@ -445,7 +483,7 @@ private function convertType(Type $type, bool $input, ?string $queryName, ?strin } if ($this->typeBuilder->isCollection($type)) { - $operationName = $queryName ?? $mutationName; + $operationName = $queryName ?? $mutationName ?? $subscriptionName; return $this->pagination->isGraphQlEnabled($resourceClass, $operationName) && !$input ? $this->typeBuilder->getResourcePaginatedCollectionType($graphqlType, $resourceClass, $operationName) : GraphQLType::listOf($graphqlType); } diff --git a/src/GraphQl/Type/FieldsBuilderInterface.php b/src/GraphQl/Type/FieldsBuilderInterface.php index c5905f073fe..765144a2af7 100644 --- a/src/GraphQl/Type/FieldsBuilderInterface.php +++ b/src/GraphQl/Type/FieldsBuilderInterface.php @@ -44,10 +44,15 @@ public function getCollectionQueryFields(string $resourceClass, ResourceMetadata */ public function getMutationFields(string $resourceClass, ResourceMetadata $resourceMetadata, string $mutationName): array; + /** + * Gets the subscription fields of the schema. + */ + public function getSubscriptionFields(string $resourceClass, ResourceMetadata $resourceMetadata, string $subscriptionName): array; + /** * Gets the fields of the type of the given resource. */ - public function getResourceObjectTypeFields(?string $resourceClass, ResourceMetadata $resourceMetadata, bool $input, ?string $queryName, ?string $mutationName, int $depth, ?array $ioMetadata): array; + public function getResourceObjectTypeFields(?string $resourceClass, ResourceMetadata $resourceMetadata, bool $input, ?string $queryName, ?string $mutationName, ?string $subscriptionName, int $depth, ?array $ioMetadata): array; /** * Resolve the args of a resource by resolving its types. diff --git a/src/GraphQl/Type/SchemaBuilder.php b/src/GraphQl/Type/SchemaBuilder.php index 1fdbfc1f2ca..3ac289c6482 100644 --- a/src/GraphQl/Type/SchemaBuilder.php +++ b/src/GraphQl/Type/SchemaBuilder.php @@ -54,6 +54,7 @@ public function getSchema(): Schema $queryFields = ['node' => $this->fieldsBuilder->getNodeQueryFields()]; $mutationFields = []; + $subscriptionFields = []; foreach ($this->resourceNameCollectionFactory->create() as $resourceClass) { $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass); @@ -84,6 +85,10 @@ public function getSchema(): Schema continue; } + if ('update' === $operationName) { + $subscriptionFields += $this->fieldsBuilder->getSubscriptionFields($resourceClass, $resourceMetadata, $operationName); + } + $mutationFields += $this->fieldsBuilder->getMutationFields($resourceClass, $resourceMetadata, $operationName); } } @@ -111,6 +116,13 @@ public function getSchema(): Schema ]); } + if ($subscriptionFields) { + $schema['subscription'] = new ObjectType([ + 'name' => 'Subscription', + 'fields' => $subscriptionFields, + ]); + } + return new Schema($schema); } } diff --git a/src/GraphQl/Type/TypeBuilder.php b/src/GraphQl/Type/TypeBuilder.php index b650bd28131..ad66a3d463b 100644 --- a/src/GraphQl/Type/TypeBuilder.php +++ b/src/GraphQl/Type/TypeBuilder.php @@ -49,16 +49,19 @@ public function __construct(TypesContainerInterface $typesContainer, callable $d /** * {@inheritdoc} */ - public function getResourceObjectType(?string $resourceClass, ResourceMetadata $resourceMetadata, bool $input, ?string $queryName, ?string $mutationName, bool $wrapped = false, int $depth = 0): GraphQLType + public function getResourceObjectType(?string $resourceClass, ResourceMetadata $resourceMetadata, bool $input, ?string $queryName, ?string $mutationName, ?string $subscriptionName, bool $wrapped = false, int $depth = 0): GraphQLType { $shortName = $resourceMetadata->getShortName(); if (null !== $mutationName) { $shortName = $mutationName.ucfirst($shortName); } + if (null !== $subscriptionName) { + $shortName = $subscriptionName.ucfirst($shortName).'Subscription'; + } if ($input) { $shortName .= 'Input'; - } elseif (null !== $mutationName) { + } elseif (null !== $mutationName || null !== $subscriptionName) { if ($depth > 0) { $shortName .= 'Nested'; } @@ -73,7 +76,7 @@ public function getResourceObjectType(?string $resourceClass, ResourceMetadata $ $shortName .= 'Collection'; } } - if ($wrapped && null !== $mutationName) { + if ($wrapped && (null !== $mutationName || null !== $subscriptionName)) { $shortName .= 'Data'; } @@ -86,36 +89,46 @@ public function getResourceObjectType(?string $resourceClass, ResourceMetadata $ return $resourceObjectType; } - $ioMetadata = $resourceMetadata->getGraphqlAttribute($mutationName ?? $queryName, $input ? 'input' : 'output', null, true); + $ioMetadata = $resourceMetadata->getGraphqlAttribute($subscriptionName ?? $mutationName ?? $queryName, $input ? 'input' : 'output', null, true); if (null !== $ioMetadata && \array_key_exists('class', $ioMetadata) && null !== $ioMetadata['class']) { $resourceClass = $ioMetadata['class']; } - $wrapData = !$wrapped && null !== $mutationName && !$input && $depth < 1; + $wrapData = !$wrapped && (null !== $mutationName || null !== $subscriptionName) && !$input && $depth < 1; $configuration = [ 'name' => $shortName, 'description' => $resourceMetadata->getDescription(), 'resolveField' => $this->defaultFieldResolver, - 'fields' => function () use ($resourceClass, $resourceMetadata, $input, $mutationName, $queryName, $wrapData, $depth, $ioMetadata) { + 'fields' => function () use ($resourceClass, $resourceMetadata, $input, $queryName, $mutationName, $subscriptionName, $wrapData, $depth, $ioMetadata) { if ($wrapData) { $queryNormalizationContext = $resourceMetadata->getGraphqlAttribute($queryName ?? '', 'normalization_context', [], true); - $mutationNormalizationContext = $resourceMetadata->getGraphqlAttribute($mutationName ?? '', 'normalization_context', [], true); - // Use a new type for the wrapped object only if there is a specific normalization context for the mutation. + $mutationNormalizationContext = $resourceMetadata->getGraphqlAttribute($mutationName ?? $subscriptionName ?? '', 'normalization_context', [], true); + // Use a new type for the wrapped object only if there is a specific normalization context for the mutation or the subscription. // If not, use the query type in order to ensure the client cache could be used. $useWrappedType = $queryNormalizationContext !== $mutationNormalizationContext; - return [ + $fields = [ lcfirst($resourceMetadata->getShortName()) => $useWrappedType ? - $this->getResourceObjectType($resourceClass, $resourceMetadata, $input, $queryName, $mutationName, true, $depth) : - $this->getResourceObjectType($resourceClass, $resourceMetadata, $input, $queryName ?? 'item_query', null, true, $depth), - 'clientMutationId' => GraphQLType::string(), + $this->getResourceObjectType($resourceClass, $resourceMetadata, $input, $queryName, $mutationName, $subscriptionName, true, $depth) : + $this->getResourceObjectType($resourceClass, $resourceMetadata, $input, $queryName ?? 'item_query', null, null, true, $depth), ]; + + if (null !== $subscriptionName) { + $fields['clientSubscriptionId'] = GraphQLType::string(); + if ($resourceMetadata->getAttribute('mercure', false)) { + $fields['mercureUrl'] = GraphQLType::string(); + } + + return $fields; + } + + return $fields + ['clientMutationId' => GraphQLType::string()]; } $fieldsBuilder = $this->fieldsBuilderLocator->get('api_platform.graphql.fields_builder'); - $fields = $fieldsBuilder->getResourceObjectTypeFields($resourceClass, $resourceMetadata, $input, $queryName, $mutationName, $depth, $ioMetadata); + $fields = $fieldsBuilder->getResourceObjectTypeFields($resourceClass, $resourceMetadata, $input, $queryName, $mutationName, $subscriptionName, $depth, $ioMetadata); if ($input && null !== $mutationName && null !== $mutationArgs = $resourceMetadata->getGraphql()[$mutationName]['args'] ?? null) { return $fieldsBuilder->resolveResourceArgs($mutationArgs, $mutationName, $resourceMetadata->getShortName()) + ['clientMutationId' => $fields['clientMutationId']]; diff --git a/src/GraphQl/Type/TypeBuilderInterface.php b/src/GraphQl/Type/TypeBuilderInterface.php index b59c2093e5a..ed5aeb75e99 100644 --- a/src/GraphQl/Type/TypeBuilderInterface.php +++ b/src/GraphQl/Type/TypeBuilderInterface.php @@ -34,7 +34,7 @@ interface TypeBuilderInterface * * @return ObjectType|NonNull the object type, possibly wrapped by NonNull */ - public function getResourceObjectType(?string $resourceClass, ResourceMetadata $resourceMetadata, bool $input, ?string $queryName, ?string $mutationName, bool $wrapped, int $depth): GraphQLType; + public function getResourceObjectType(?string $resourceClass, ResourceMetadata $resourceMetadata, bool $input, ?string $queryName, ?string $mutationName, ?string $subscriptionName, bool $wrapped, int $depth): GraphQLType; /** * Get the interface type of a node. diff --git a/src/GraphQl/Type/TypeConverter.php b/src/GraphQl/Type/TypeConverter.php index b6f09a8a8d7..db7cec25557 100644 --- a/src/GraphQl/Type/TypeConverter.php +++ b/src/GraphQl/Type/TypeConverter.php @@ -49,7 +49,7 @@ public function __construct(TypeBuilderInterface $typeBuilder, TypesContainerInt /** * {@inheritdoc} */ - public function convertType(Type $type, bool $input, ?string $queryName, ?string $mutationName, string $resourceClass, string $rootResource, ?string $property, int $depth) + public function convertType(Type $type, bool $input, ?string $queryName, ?string $mutationName, ?string $subscriptionName, string $resourceClass, string $rootResource, ?string $property, int $depth) { switch ($type->getBuiltinType()) { case Type::BUILTIN_TYPE_BOOL: @@ -72,7 +72,7 @@ public function convertType(Type $type, bool $input, ?string $queryName, ?string return GraphQLType::string(); } - return $this->getResourceType($type, $input, $queryName, $mutationName, $depth); + return $this->getResourceType($type, $input, $queryName, $mutationName, $subscriptionName, $depth); default: return null; } @@ -96,7 +96,7 @@ public function resolveType(string $type): ?GraphQLType throw new InvalidArgumentException(sprintf('The type "%s" was not resolved.', $type)); } - private function getResourceType(Type $type, bool $input, ?string $queryName, ?string $mutationName, int $depth): ?GraphQLType + private function getResourceType(Type $type, bool $input, ?string $queryName, ?string $mutationName, ?string $subscriptionName, int $depth): ?GraphQLType { $resourceClass = $this->typeBuilder->isCollection($type) && ($collectionValueType = $type->getCollectionValueType()) ? $collectionValueType->getClassName() : $type->getClassName(); if (null === $resourceClass) { @@ -113,7 +113,7 @@ private function getResourceType(Type $type, bool $input, ?string $queryName, ?s return null; } - return $this->typeBuilder->getResourceObjectType($resourceClass, $resourceMetadata, $input, $queryName, $mutationName, false, $depth); + return $this->typeBuilder->getResourceObjectType($resourceClass, $resourceMetadata, $input, $queryName, $mutationName, $subscriptionName, false, $depth); } private function resolveAstTypeNode(TypeNode $astTypeNode, string $fromType): ?GraphQLType diff --git a/src/GraphQl/Type/TypeConverterInterface.php b/src/GraphQl/Type/TypeConverterInterface.php index 04f73c581d0..61373d15c3c 100644 --- a/src/GraphQl/Type/TypeConverterInterface.php +++ b/src/GraphQl/Type/TypeConverterInterface.php @@ -31,7 +31,7 @@ interface TypeConverterInterface * * @return string|GraphQLType|null */ - public function convertType(Type $type, bool $input, ?string $queryName, ?string $mutationName, string $resourceClass, string $rootResource, ?string $property, int $depth); + public function convertType(Type $type, bool $input, ?string $queryName, ?string $mutationName, ?string $subscriptionName, string $resourceClass, string $rootResource, ?string $property, int $depth); /** * Resolves a type written with the GraphQL type system to its object representation. diff --git a/src/Util/SortTrait.php b/src/Util/SortTrait.php new file mode 100644 index 00000000000..56a23fae9af --- /dev/null +++ b/src/Util/SortTrait.php @@ -0,0 +1,35 @@ + + * + * 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\Util; + +/** + * Sort helper methods. + * + * @internal + * + * @author Alan Poulain + */ +trait SortTrait +{ + private function arrayRecursiveSort(array &$array, callable $sortFunction): void + { + foreach ($array as &$value) { + if (\is_array($value)) { + $this->arrayRecursiveSort($value, $sortFunction); + } + } + unset($value); + $sortFunction($array); + } +} diff --git a/tests/Bridge/Doctrine/EventListener/PublishMercureUpdatesListenerTest.php b/tests/Bridge/Doctrine/EventListener/PublishMercureUpdatesListenerTest.php index 4899cdd3d56..26d7954ce86 100644 --- a/tests/Bridge/Doctrine/EventListener/PublishMercureUpdatesListenerTest.php +++ b/tests/Bridge/Doctrine/EventListener/PublishMercureUpdatesListenerTest.php @@ -18,6 +18,8 @@ use ApiPlatform\Core\Api\UrlGeneratorInterface; use ApiPlatform\Core\Bridge\Doctrine\EventListener\PublishMercureUpdatesListener; use ApiPlatform\Core\Exception\InvalidArgumentException; +use ApiPlatform\Core\GraphQl\Subscription\MercureSubscriptionIriGeneratorInterface as GraphQlMercureSubscriptionIriGeneratorInterface; +use ApiPlatform\Core\GraphQl\Subscription\SubscriptionManagerInterface as GraphQlSubscriptionManagerInterface; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; use ApiPlatform\Core\Tests\Fixtures\NotAResource; @@ -115,6 +117,74 @@ public function testPublishUpdate(): void $this->assertSame([[], [], [], ['foo', 'bar']], $targets); } + public function testPublishGraphQlUpdates(): void + { + $toUpdate = new Dummy(); + $toUpdate->setId(2); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass(Argument::type(Dummy::class))->willReturn(Dummy::class); + $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getIriFromItem($toUpdate, UrlGeneratorInterface::ABS_URL)->willReturn('http://example.com/dummies/2'); + + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataFactoryProphecy->create(Dummy::class)->willReturn(new ResourceMetadata(null, null, null, null, null, ['mercure' => true, 'normalization_context' => ['groups' => ['foo', 'bar']]])); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->serialize($toUpdate, 'jsonld', ['groups' => ['foo', 'bar']])->willReturn('2'); + + $formats = ['jsonld' => ['application/ld+json'], 'jsonhal' => ['application/hal+json']]; + + $topics = []; + $targets = []; + $data = []; + $publisher = function (Update $update) use (&$topics, &$targets, &$data): string { + $topics = array_merge($topics, $update->getTopics()); + $targets[] = $update->getTargets(); + $data[] = $update->getData(); + + return 'id'; + }; + + $graphQlSubscriptionManagerProphecy = $this->prophesize(GraphQlSubscriptionManagerInterface::class); + $graphQlSubscriptionId = 'subscription-id'; + $graphQlSubscriptionData = ['data']; + $graphQlSubscriptionManagerProphecy->getPushPayloads($toUpdate)->willReturn([[$graphQlSubscriptionId, $graphQlSubscriptionData]]); + $graphQlMercureSubscriptionIriGenerator = $this->prophesize(GraphQlMercureSubscriptionIriGeneratorInterface::class); + $topicIri = 'subscription-topic-iri'; + $graphQlMercureSubscriptionIriGenerator->generateTopicIri($graphQlSubscriptionId)->willReturn($topicIri); + + $listener = new PublishMercureUpdatesListener( + $resourceClassResolverProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceMetadataFactoryProphecy->reveal(), + $serializerProphecy->reveal(), + $formats, + null, + $publisher, + $graphQlSubscriptionManagerProphecy->reveal(), + $graphQlMercureSubscriptionIriGenerator->reveal() + ); + + $uowProphecy = $this->prophesize(UnitOfWork::class); + $uowProphecy->getScheduledEntityInsertions()->willReturn([])->shouldBeCalled(); + $uowProphecy->getScheduledEntityUpdates()->willReturn([$toUpdate])->shouldBeCalled(); + $uowProphecy->getScheduledEntityDeletions()->willReturn([])->shouldBeCalled(); + + $emProphecy = $this->prophesize(EntityManagerInterface::class); + $emProphecy->getUnitOfWork()->willReturn($uowProphecy->reveal())->shouldBeCalled(); + $eventArgs = new OnFlushEventArgs($emProphecy->reveal()); + + $listener->onFlush($eventArgs); + $listener->postFlush(); + + $this->assertSame(['http://example.com/dummies/2', 'subscription-topic-iri'], $topics); + $this->assertSame([[], []], $targets); + $this->assertSame(['2', '["data"]'], $data); + } + public function testNoPublisher(): void { $this->expectException(InvalidArgumentException::class); diff --git a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php index 89bbcc5ab0a..604c1d508eb 100644 --- a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php +++ b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php @@ -342,6 +342,7 @@ public function testDisableGraphQl() $containerBuilderProphecy->setDefinition('api_platform.graphql.action.graphql_playground', Argument::type(Definition::class))->shouldNotBeCalled(); $containerBuilderProphecy->setDefinition('api_platform.graphql.resolver.factory.collection', Argument::type(Definition::class))->shouldNotBeCalled(); $containerBuilderProphecy->setDefinition('api_platform.graphql.resolver.factory.item_mutation', Argument::type(Definition::class))->shouldNotBeCalled(); + $containerBuilderProphecy->setDefinition('api_platform.graphql.resolver.factory.item_subscription', Argument::type(Definition::class))->shouldNotBeCalled(); $containerBuilderProphecy->setDefinition('api_platform.graphql.resolver.factory.item', Argument::type(Definition::class))->shouldNotBeCalled(); $containerBuilderProphecy->setDefinition('api_platform.graphql.resolver.stage.read', Argument::type(Definition::class))->shouldNotBeCalled(); $containerBuilderProphecy->setDefinition('api_platform.graphql.resolver.stage.security', Argument::type(Definition::class))->shouldNotBeCalled(); @@ -371,7 +372,11 @@ public function testDisableGraphQl() $containerBuilderProphecy->setDefinition('api_platform.graphql.normalizer.validation_exception', Argument::type(Definition::class))->shouldNotBeCalled(); $containerBuilderProphecy->setDefinition('api_platform.graphql.normalizer.http_exception', Argument::type(Definition::class))->shouldNotBeCalled(); $containerBuilderProphecy->setDefinition('api_platform.graphql.normalizer.runtime_exception', Argument::type(Definition::class))->shouldNotBeCalled(); + $containerBuilderProphecy->setDefinition('api_platform.graphql.subscription.subscription_manager', Argument::type(Definition::class))->shouldNotBeCalled(); + $containerBuilderProphecy->setDefinition('api_platform.graphql.subscription.subscription_identifier_generator', Argument::type(Definition::class))->shouldNotBeCalled(); + $containerBuilderProphecy->setDefinition('api_platform.graphql.cache.subscription', Argument::type(Definition::class))->shouldNotBeCalled(); $containerBuilderProphecy->setDefinition('api_platform.graphql.command.export_command', Argument::type(Definition::class))->shouldNotBeCalled(); + $containerBuilderProphecy->setDefinition('api_platform.graphql.subscription.mercure_iri_generator', Argument::type(Definition::class))->shouldNotBeCalled(); $containerBuilderProphecy->setParameter('api_platform.graphql.enabled', true)->shouldNotBeCalled(); $containerBuilderProphecy->setParameter('api_platform.graphql.enabled', false)->shouldBeCalled(); $containerBuilderProphecy->setParameter('api_platform.graphql.default_ide', 'graphiql')->shouldNotBeCalled(); @@ -1180,6 +1185,7 @@ private function getBaseContainerBuilderProphecy(array $doctrineIntegrationsToLo 'api_platform.graphql.resolver.factory.item', 'api_platform.graphql.resolver.factory.collection', 'api_platform.graphql.resolver.factory.item_mutation', + 'api_platform.graphql.resolver.factory.item_subscription', 'api_platform.graphql.resolver.stage.read', 'api_platform.graphql.resolver.stage.security', 'api_platform.graphql.resolver.stage.security_post_denormalize', @@ -1203,7 +1209,11 @@ private function getBaseContainerBuilderProphecy(array $doctrineIntegrationsToLo 'api_platform.graphql.normalizer.item', 'api_platform.graphql.normalizer.object', 'api_platform.graphql.serializer.context_builder', + 'api_platform.graphql.subscription.subscription_manager', + 'api_platform.graphql.subscription.subscription_identifier_generator', + 'api_platform.graphql.cache.subscription', 'api_platform.graphql.command.export_command', + 'api_platform.graphql.subscription.mercure_iri_generator', 'api_platform.hal.encoder', 'api_platform.hal.normalizer.collection', 'api_platform.hal.normalizer.entrypoint', @@ -1365,6 +1375,7 @@ private function getBaseContainerBuilderProphecy(array $doctrineIntegrationsToLo $containerBuilderProphecy->getDefinition('api_platform.mercure.listener.response.add_link_header')->willReturn($definitionDummy); $containerBuilderProphecy->getDefinition('api_platform.doctrine.orm.listener.mercure.publish')->willReturn($definitionDummy); $containerBuilderProphecy->getDefinition('api_platform.doctrine_mongodb.odm.listener.mercure.publish')->willReturn($definitionDummy); + $containerBuilderProphecy->getDefinition('api_platform.graphql.subscription.mercure_iri_generator')->willReturn($definitionDummy); return $containerBuilderProphecy; } diff --git a/tests/Fixtures/DummyMercurePublisher.php b/tests/Fixtures/DummyMercurePublisher.php index efed15a6900..d2e8b94d206 100644 --- a/tests/Fixtures/DummyMercurePublisher.php +++ b/tests/Fixtures/DummyMercurePublisher.php @@ -17,8 +17,20 @@ class DummyMercurePublisher { + private $updates = []; + public function __invoke(Update $update): string { + $this->updates[] = $update; + return 'dummy'; } + + /** + * @return array + */ + public function getUpdates(): array + { + return $this->updates; + } } diff --git a/tests/Fixtures/TestBundle/Document/DummyMercure.php b/tests/Fixtures/TestBundle/Document/DummyMercure.php index 25177c85bd1..5a4b9cc23e5 100644 --- a/tests/Fixtures/TestBundle/Document/DummyMercure.php +++ b/tests/Fixtures/TestBundle/Document/DummyMercure.php @@ -28,4 +28,19 @@ class DummyMercure * @ODM\Id(strategy="INCREMENT", type="integer") */ public $id; + + /** + * @ODM\Field(type="string") + */ + public $name; + + /** + * @ODM\Field(type="string") + */ + public $description; + + /** + * @ODM\ReferenceOne(targetDocument=RelatedDummy::class, storeAs="id", nullable=true) + */ + public $relatedDummy; } diff --git a/tests/Fixtures/TestBundle/Entity/DummyMercure.php b/tests/Fixtures/TestBundle/Entity/DummyMercure.php index a6190fba197..ebd7eb920a4 100644 --- a/tests/Fixtures/TestBundle/Entity/DummyMercure.php +++ b/tests/Fixtures/TestBundle/Entity/DummyMercure.php @@ -26,7 +26,23 @@ class DummyMercure { /** * @ORM\Id - * @ORM\Column(type="string") + * @ORM\Column(type="integer") + * @ORM\GeneratedValue(strategy="AUTO") */ public $id; + + /** + * @ORM\Column + */ + public $name; + + /** + * @ORM\Column + */ + public $description; + + /** + * @ORM\ManyToOne(targetEntity="RelatedDummy") + */ + public $relatedDummy; } diff --git a/tests/Fixtures/TestBundle/GraphQl/Type/TypeConverter.php b/tests/Fixtures/TestBundle/GraphQl/Type/TypeConverter.php index 69f4e86457c..39b440ff7ee 100644 --- a/tests/Fixtures/TestBundle/GraphQl/Type/TypeConverter.php +++ b/tests/Fixtures/TestBundle/GraphQl/Type/TypeConverter.php @@ -36,7 +36,7 @@ public function __construct(TypeConverterInterface $defaultTypeConverter) /** * {@inheritdoc} */ - public function convertType(Type $type, bool $input, ?string $queryName, ?string $mutationName, string $resourceClass, string $rootResource, ?string $property, int $depth) + public function convertType(Type $type, bool $input, ?string $queryName, ?string $mutationName, ?string $subscriptionName, string $resourceClass, string $rootResource, ?string $property, int $depth) { if ('dummyDate' === $property && \in_array($rootResource, [Dummy::class, DummyDocument::class], true) @@ -46,7 +46,7 @@ public function convertType(Type $type, bool $input, ?string $queryName, ?string return 'DateTime'; } - return $this->defaultTypeConverter->convertType($type, $input, $queryName, $mutationName, $resourceClass, $rootResource, $property, $depth); + return $this->defaultTypeConverter->convertType($type, $input, $queryName, $mutationName, $subscriptionName, $resourceClass, $rootResource, $property, $depth); } /** diff --git a/tests/Fixtures/app/config/config_common.yml b/tests/Fixtures/app/config/config_common.yml index 0fe02d4b37d..94c452a69bf 100644 --- a/tests/Fixtures/app/config/config_common.yml +++ b/tests/Fixtures/app/config/config_common.yml @@ -235,6 +235,8 @@ services: mercure.hub.default.publisher: class: ApiPlatform\Core\Tests\Fixtures\DummyMercurePublisher + public: true + tags: ['messenger.message_handler'] app.serializer.normalizer.override_documentation: class: ApiPlatform\Core\Tests\Fixtures\TestBundle\Serializer\Normalizer\OverrideDocumentationNormalizer diff --git a/tests/GraphQl/Resolver/Factory/CollectionResolverFactoryTest.php b/tests/GraphQl/Resolver/Factory/CollectionResolverFactoryTest.php index 960306d3832..0fe3d62762c 100644 --- a/tests/GraphQl/Resolver/Factory/CollectionResolverFactoryTest.php +++ b/tests/GraphQl/Resolver/Factory/CollectionResolverFactoryTest.php @@ -74,7 +74,7 @@ public function testResolve(): void $source = ['source']; $args = ['args']; $info = $this->prophesize(ResolveInfo::class)->reveal(); - $resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => true, 'is_mutation' => false]; + $resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => true, 'is_mutation' => false, 'is_subscription' => false]; $request = new Request(); $attributesParameterBagProphecy = $this->prophesize(ParameterBag::class); @@ -138,7 +138,7 @@ public function testResolveBadReadStageCollection(): void $source = ['source']; $args = ['args']; $info = $this->prophesize(ResolveInfo::class)->reveal(); - $resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => true, 'is_mutation' => false]; + $resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => true, 'is_mutation' => false, 'is_subscription' => false]; $readStageCollection = new \stdClass(); $this->readStageProphecy->__invoke($resourceClass, $rootClass, $operationName, $resolverContext)->shouldBeCalled()->willReturn($readStageCollection); @@ -157,7 +157,7 @@ public function testResolveCustom(): void $source = ['source']; $args = ['args']; $info = $this->prophesize(ResolveInfo::class)->reveal(); - $resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => true, 'is_mutation' => false]; + $resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => true, 'is_mutation' => false, 'is_subscription' => false]; $readStageCollection = [new \stdClass()]; $this->readStageProphecy->__invoke($resourceClass, $rootClass, $operationName, $resolverContext)->shouldBeCalled()->willReturn($readStageCollection); diff --git a/tests/GraphQl/Resolver/Factory/ItemMutationResolverFactoryTest.php b/tests/GraphQl/Resolver/Factory/ItemMutationResolverFactoryTest.php index ce283a65e33..da160d8c2b8 100644 --- a/tests/GraphQl/Resolver/Factory/ItemMutationResolverFactoryTest.php +++ b/tests/GraphQl/Resolver/Factory/ItemMutationResolverFactoryTest.php @@ -81,7 +81,7 @@ public function testResolve(): void $source = ['source']; $args = ['args']; $info = $this->prophesize(ResolveInfo::class)->reveal(); - $resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => false, 'is_mutation' => true]; + $resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => false, 'is_mutation' => true, 'is_subscription' => false]; $readStageItem = new \stdClass(); $readStageItem->field = 'read'; @@ -149,7 +149,7 @@ public function testResolveBadReadStageItem(): void $source = ['source']; $args = ['args']; $info = $this->prophesize(ResolveInfo::class)->reveal(); - $resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => false, 'is_mutation' => true]; + $resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => false, 'is_mutation' => true, 'is_subscription' => false]; $readStageItem = []; $this->readStageProphecy->__invoke($resourceClass, $rootClass, $operationName, $resolverContext)->shouldBeCalled()->willReturn($readStageItem); @@ -168,7 +168,7 @@ public function testResolveNullDeserializeStageItem(): void $source = ['source']; $args = ['args']; $info = $this->prophesize(ResolveInfo::class)->reveal(); - $resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => false, 'is_mutation' => true]; + $resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => false, 'is_mutation' => true, 'is_subscription' => false]; $readStageItem = new \stdClass(); $readStageItem->field = 'read'; @@ -209,7 +209,7 @@ public function testResolveDelete(): void $source = ['source']; $args = ['args']; $info = $this->prophesize(ResolveInfo::class)->reveal(); - $resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => false, 'is_mutation' => true]; + $resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => false, 'is_mutation' => true, 'is_subscription' => false]; $readStageItem = new \stdClass(); $readStageItem->field = 'read'; @@ -249,7 +249,7 @@ public function testResolveCustom(): void $source = ['source']; $args = ['args']; $info = $this->prophesize(ResolveInfo::class)->reveal(); - $resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => false, 'is_mutation' => true]; + $resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => false, 'is_mutation' => true, 'is_subscription' => false]; $readStageItem = new \stdClass(); $readStageItem->field = 'read'; @@ -301,7 +301,7 @@ public function testResolveCustomBadItem(): void $source = ['source']; $args = ['args']; $info = $this->prophesize(ResolveInfo::class)->reveal(); - $resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => false, 'is_mutation' => true]; + $resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => false, 'is_mutation' => true, 'is_subscription' => false]; $readStageItem = new \stdClass(); $readStageItem->field = 'read'; diff --git a/tests/GraphQl/Resolver/Factory/ItemResolverFactoryTest.php b/tests/GraphQl/Resolver/Factory/ItemResolverFactoryTest.php index bae61f2a5b2..8d91e2774b5 100644 --- a/tests/GraphQl/Resolver/Factory/ItemResolverFactoryTest.php +++ b/tests/GraphQl/Resolver/Factory/ItemResolverFactoryTest.php @@ -73,7 +73,7 @@ public function testResolve(?string $resourceClass, string $determinedResourceCl $source = ['source']; $args = ['args']; $info = $this->prophesize(ResolveInfo::class)->reveal(); - $resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => false, 'is_mutation' => false]; + $resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => false, 'is_mutation' => false, 'is_subscription' => false]; $this->readStageProphecy->__invoke($resourceClass, $rootClass, $operationName, $resolverContext)->shouldBeCalled()->willReturn($readStageItem); @@ -123,7 +123,7 @@ public function testResolveBadReadStageItem(): void $source = ['source']; $args = ['args']; $info = $this->prophesize(ResolveInfo::class)->reveal(); - $resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => false, 'is_mutation' => false]; + $resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => false, 'is_mutation' => false, 'is_subscription' => false]; $readStageItem = []; $this->readStageProphecy->__invoke($resourceClass, $rootClass, $operationName, $resolverContext)->shouldBeCalled()->willReturn($readStageItem); @@ -142,7 +142,7 @@ public function testResolveNoResourceNoItem(): void $source = ['source']; $args = ['args']; $info = $this->prophesize(ResolveInfo::class)->reveal(); - $resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => false, 'is_mutation' => false]; + $resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => false, 'is_mutation' => false, 'is_subscription' => false]; $readStageItem = null; $this->readStageProphecy->__invoke($resourceClass, $rootClass, $operationName, $resolverContext)->shouldBeCalled()->willReturn($readStageItem); @@ -161,7 +161,7 @@ public function testResolveBadItem(): void $source = ['source']; $args = ['args']; $info = $this->prophesize(ResolveInfo::class)->reveal(); - $resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => false, 'is_mutation' => false]; + $resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => false, 'is_mutation' => false, 'is_subscription' => false]; $readStageItem = new \stdClass(); $this->readStageProphecy->__invoke($resourceClass, $rootClass, $operationName, $resolverContext)->shouldBeCalled()->willReturn($readStageItem); @@ -180,7 +180,7 @@ public function testResolveCustom(): void $source = ['source']; $args = ['args']; $info = $this->prophesize(ResolveInfo::class)->reveal(); - $resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => false, 'is_mutation' => false]; + $resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => false, 'is_mutation' => false, 'is_subscription' => false]; $readStageItem = new \stdClass(); $this->readStageProphecy->__invoke($resourceClass, $rootClass, $operationName, $resolverContext)->shouldBeCalled()->willReturn($readStageItem); @@ -221,7 +221,7 @@ public function testResolveCustomBadItem(): void $source = ['source']; $args = ['args']; $info = $this->prophesize(ResolveInfo::class)->reveal(); - $resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => false, 'is_mutation' => false]; + $resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => false, 'is_mutation' => false, 'is_subscription' => false]; $readStageItem = new \stdClass(); $this->readStageProphecy->__invoke($resourceClass, $rootClass, $operationName, $resolverContext)->shouldBeCalled()->willReturn($readStageItem); diff --git a/tests/GraphQl/Resolver/Factory/ItemSubscriptionResolverFactoryTest.php b/tests/GraphQl/Resolver/Factory/ItemSubscriptionResolverFactoryTest.php new file mode 100644 index 00000000000..2d6240366a3 --- /dev/null +++ b/tests/GraphQl/Resolver/Factory/ItemSubscriptionResolverFactoryTest.php @@ -0,0 +1,199 @@ + + * + * 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\Tests\GraphQl\Resolver\Factory; + +use ApiPlatform\Core\GraphQl\Resolver\Factory\ItemSubscriptionResolverFactory; +use ApiPlatform\Core\GraphQl\Resolver\Stage\ReadStageInterface; +use ApiPlatform\Core\GraphQl\Resolver\Stage\SecurityStageInterface; +use ApiPlatform\Core\GraphQl\Resolver\Stage\SerializeStageInterface; +use ApiPlatform\Core\GraphQl\Subscription\MercureSubscriptionIriGeneratorInterface; +use ApiPlatform\Core\GraphQl\Subscription\SubscriptionManagerInterface; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; +use GraphQL\Type\Definition\ResolveInfo; +use PHPUnit\Framework\TestCase; +use Prophecy\Argument; + +/** + * @author Alan Poulain + */ +class ItemSubscriptionResolverFactoryTest extends TestCase +{ + private $itemSubscriptionResolverFactory; + private $readStageProphecy; + private $securityStageProphecy; + private $serializeStageProphecy; + private $resourceMetadataFactoryProphecy; + private $subscriptionManagerProphecy; + private $mercureSubscriptionIriGeneratorProphecy; + + /** + * {@inheritdoc} + */ + protected function setUp(): void + { + $this->readStageProphecy = $this->prophesize(ReadStageInterface::class); + $this->securityStageProphecy = $this->prophesize(SecurityStageInterface::class); + $this->serializeStageProphecy = $this->prophesize(SerializeStageInterface::class); + $this->resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $this->subscriptionManagerProphecy = $this->prophesize(SubscriptionManagerInterface::class); + $this->mercureSubscriptionIriGeneratorProphecy = $this->prophesize(MercureSubscriptionIriGeneratorInterface::class); + + $this->itemSubscriptionResolverFactory = new ItemSubscriptionResolverFactory( + $this->readStageProphecy->reveal(), + $this->securityStageProphecy->reveal(), + $this->serializeStageProphecy->reveal(), + $this->resourceMetadataFactoryProphecy->reveal(), + $this->subscriptionManagerProphecy->reveal(), + $this->mercureSubscriptionIriGeneratorProphecy->reveal() + ); + } + + public function testResolve(): void + { + $resourceClass = 'stdClass'; + $rootClass = 'rootClass'; + $operationName = 'update'; + $source = ['source']; + $args = ['args']; + $info = $this->prophesize(ResolveInfo::class)->reveal(); + $resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => false, 'is_mutation' => false, 'is_subscription' => true]; + + $readStageItem = new \stdClass(); + $this->readStageProphecy->__invoke($resourceClass, $rootClass, $operationName, $resolverContext)->shouldBeCalled()->willReturn($readStageItem); + + $this->securityStageProphecy->__invoke($resourceClass, $operationName, $resolverContext + [ + 'extra_variables' => [ + 'object' => $readStageItem, + ], + ])->shouldBeCalled(); + + $serializeStageData = ['serialized']; + $this->serializeStageProphecy->__invoke($readStageItem, $resourceClass, $operationName, $resolverContext)->shouldBeCalled()->willReturn($serializeStageData); + + $subscriptionId = 'subscriptionId'; + $this->subscriptionManagerProphecy->retrieveSubscriptionId($resolverContext, $serializeStageData)->shouldBeCalled()->willReturn($subscriptionId); + + $this->resourceMetadataFactoryProphecy->create($resourceClass)->willReturn((new ResourceMetadata())->withAttributes(['mercure' => true])); + + $mercureUrl = 'mercure-url'; + $this->mercureSubscriptionIriGeneratorProphecy->generateMercureUrl($subscriptionId)->shouldBeCalled()->willReturn($mercureUrl); + + $this->assertSame($serializeStageData + ['mercureUrl' => $mercureUrl], ($this->itemSubscriptionResolverFactory)($resourceClass, $rootClass, $operationName)($source, $args, null, $info)); + } + + public function testResolveNullResourceClass(): void + { + $resourceClass = null; + $rootClass = 'rootClass'; + $operationName = 'update'; + $source = ['source']; + $args = ['args']; + $info = $this->prophesize(ResolveInfo::class)->reveal(); + + $this->assertNull(($this->itemSubscriptionResolverFactory)($resourceClass, $rootClass, $operationName)($source, $args, null, $info)); + } + + public function testResolveNullOperationName(): void + { + $resourceClass = 'stdClass'; + $rootClass = 'rootClass'; + $operationName = null; + $source = ['source']; + $args = ['args']; + $info = $this->prophesize(ResolveInfo::class)->reveal(); + + $this->assertNull(($this->itemSubscriptionResolverFactory)($resourceClass, $rootClass, $operationName)($source, $args, null, $info)); + } + + public function testResolveBadReadStageItem(): void + { + $resourceClass = 'stdClass'; + $rootClass = 'rootClass'; + $operationName = 'update'; + $source = ['source']; + $args = ['args']; + $info = $this->prophesize(ResolveInfo::class)->reveal(); + $resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => false, 'is_mutation' => false, 'is_subscription' => true]; + + $readStageItem = []; + $this->readStageProphecy->__invoke($resourceClass, $rootClass, $operationName, $resolverContext)->shouldBeCalled()->willReturn($readStageItem); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Item from read stage should be a nullable object.'); + + ($this->itemSubscriptionResolverFactory)($resourceClass, $rootClass, $operationName)($source, $args, null, $info); + } + + public function testResolveNoSubscriptionId(): void + { + $resourceClass = 'stdClass'; + $rootClass = 'rootClass'; + $operationName = 'update'; + $source = ['source']; + $args = ['args']; + $info = $this->prophesize(ResolveInfo::class)->reveal(); + $resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => false, 'is_mutation' => false, 'is_subscription' => true]; + + $readStageItem = new \stdClass(); + $this->readStageProphecy->__invoke($resourceClass, $rootClass, $operationName, $resolverContext)->willReturn($readStageItem); + + $serializeStageData = ['serialized']; + $this->serializeStageProphecy->__invoke($readStageItem, $resourceClass, $operationName, $resolverContext)->willReturn($serializeStageData); + + $this->subscriptionManagerProphecy->retrieveSubscriptionId($resolverContext, $serializeStageData)->willReturn(null); + + $this->resourceMetadataFactoryProphecy->create($resourceClass)->willReturn((new ResourceMetadata())->withAttributes(['mercure' => true])); + + $this->mercureSubscriptionIriGeneratorProphecy->generateMercureUrl(Argument::any())->shouldNotBeCalled(); + + $this->assertSame($serializeStageData, ($this->itemSubscriptionResolverFactory)($resourceClass, $rootClass, $operationName)($source, $args, null, $info)); + } + + public function testResolveNoMercureSubscriptionIriGenerator(): void + { + $resourceClass = 'stdClass'; + $rootClass = 'rootClass'; + $operationName = 'update'; + $source = ['source']; + $args = ['args']; + $info = $this->prophesize(ResolveInfo::class)->reveal(); + $resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => false, 'is_mutation' => false, 'is_subscription' => true]; + + $readStageItem = new \stdClass(); + $this->readStageProphecy->__invoke($resourceClass, $rootClass, $operationName, $resolverContext)->willReturn($readStageItem); + + $serializeStageData = ['serialized']; + $this->serializeStageProphecy->__invoke($readStageItem, $resourceClass, $operationName, $resolverContext)->willReturn($serializeStageData); + + $subscriptionId = 'subscriptionId'; + $this->subscriptionManagerProphecy->retrieveSubscriptionId($resolverContext, $serializeStageData)->willReturn($subscriptionId); + + $this->resourceMetadataFactoryProphecy->create($resourceClass)->willReturn((new ResourceMetadata())->withAttributes(['mercure' => true])); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Cannot use Mercure for subscriptions when MercureBundle is not installed. Try running "composer require mercure".'); + + $itemSubscriptionResolverFactory = new ItemSubscriptionResolverFactory( + $this->readStageProphecy->reveal(), + $this->securityStageProphecy->reveal(), + $this->serializeStageProphecy->reveal(), + $this->resourceMetadataFactoryProphecy->reveal(), + $this->subscriptionManagerProphecy->reveal(), + null + ); + + ($itemSubscriptionResolverFactory)($resourceClass, $rootClass, $operationName)($source, $args, null, $info); + } +} diff --git a/tests/GraphQl/Resolver/Stage/ReadStageTest.php b/tests/GraphQl/Resolver/Stage/ReadStageTest.php index fce8e9e6c65..d4138bac209 100644 --- a/tests/GraphQl/Resolver/Stage/ReadStageTest.php +++ b/tests/GraphQl/Resolver/Stage/ReadStageTest.php @@ -101,6 +101,7 @@ public function testApplyItem(?string $identifier, $item, bool $throwNotFound, $ $context = [ 'is_collection' => false, 'is_mutation' => false, + 'is_subscription' => false, 'args' => ['id' => $identifier], 'info' => $info, ]; @@ -132,18 +133,19 @@ public function itemProvider(): array } /** - * @dataProvider itemMutationProvider + * @dataProvider itemMutationOrSubscriptionProvider * * @param object|null $item * @param object|null $expectedResult */ - public function testApplyMutation(string $resourceClass, ?string $identifier, $item, bool $throwNotFound, $expectedResult, ?string $expectedExceptionClass = null, ?string $expectedExceptionMessage = null): void + public function testApplyMutationOrSubscription(bool $isMutation, bool $isSubscription, string $resourceClass, ?string $identifier, $item, bool $throwNotFound, $expectedResult, ?string $expectedExceptionClass = null, ?string $expectedExceptionMessage = null): void { $operationName = 'create'; $info = $this->prophesize(ResolveInfo::class)->reveal(); $context = [ 'is_collection' => false, - 'is_mutation' => true, + 'is_mutation' => $isMutation, + 'is_subscription' => $isSubscription, 'args' => ['input' => ['id' => $identifier]], 'info' => $info, ]; @@ -168,15 +170,17 @@ public function testApplyMutation(string $resourceClass, ?string $identifier, $i $this->assertSame($expectedResult, $result); } - public function itemMutationProvider(): array + public function itemMutationOrSubscriptionProvider(): array { $item = new \stdClass(); return [ - 'no identifier' => ['myResource', null, $item, false, null], - 'identifier' => ['stdClass', 'identifier', $item, false, $item], - 'identifier bad item' => ['myResource', 'identifier', $item, false, $item, \UnexpectedValueException::class, 'Item "identifier" did not match expected type "shortName".'], - 'identifier not found' => ['myResource', 'identifier_not_found', $item, true, null, NotFoundHttpException::class, 'Item "identifier_not_found" not found.'], + 'no identifier' => [true, false, 'myResource', null, $item, false, null], + 'identifier' => [true, false, 'stdClass', 'identifier', $item, false, $item], + 'identifier bad item' => [true, false, 'myResource', 'identifier', $item, false, $item, \UnexpectedValueException::class, 'Item "identifier" did not match expected type "shortName".'], + 'identifier not found' => [true, false, 'myResource', 'identifier_not_found', $item, true, null, NotFoundHttpException::class, 'Item "identifier_not_found" not found.'], + 'no identifier (subscription)' => [false, true, 'myResource', null, $item, false, null], + 'identifier (subscription)' => [false, true, 'stdClass', 'identifier', $item, false, $item], ]; } @@ -193,6 +197,7 @@ public function testApplyCollection(array $args, ?string $rootClass, ?array $sou $context = [ 'is_collection' => true, 'is_mutation' => false, + 'is_subscription' => false, 'args' => $args, 'info' => $info, 'source' => $source, diff --git a/tests/GraphQl/Resolver/Stage/SerializeStageTest.php b/tests/GraphQl/Resolver/Stage/SerializeStageTest.php index cade3bbdf2c..dc00400d626 100644 --- a/tests/GraphQl/Resolver/Stage/SerializeStageTest.php +++ b/tests/GraphQl/Resolver/Stage/SerializeStageTest.php @@ -64,10 +64,11 @@ public function testApplyDisabled(array $context, bool $paginationEnabled, ?arra public function applyDisabledProvider(): array { return [ - 'item' => [['is_collection' => false, 'is_mutation' => false], false, null], - 'collection with pagination' => [['is_collection' => true, 'is_mutation' => false], true, ['totalCount' => 0., 'edges' => [], 'pageInfo' => ['startCursor' => null, 'endCursor' => null, 'hasNextPage' => false, 'hasPreviousPage' => false]]], - 'collection without pagination' => [['is_collection' => true, 'is_mutation' => false], false, []], - 'mutation' => [['is_collection' => false, 'is_mutation' => true], false, ['clientMutationId' => null]], + 'item' => [['is_collection' => false, 'is_mutation' => false, 'is_subscription' => false], false, null], + 'collection with pagination' => [['is_collection' => true, 'is_mutation' => false, 'is_subscription' => false], true, ['totalCount' => 0., 'edges' => [], 'pageInfo' => ['startCursor' => null, 'endCursor' => null, 'hasNextPage' => false, 'hasPreviousPage' => false]]], + 'collection without pagination' => [['is_collection' => true, 'is_mutation' => false, 'is_subscription' => false], false, []], + 'mutation' => [['is_collection' => false, 'is_mutation' => true, 'is_subscription' => false], false, ['clientMutationId' => null]], + 'subscription' => [['is_collection' => false, 'is_mutation' => false, 'is_subscription' => true], false, ['clientSubscriptionId' => null]], ]; } @@ -99,10 +100,11 @@ public function applyProvider(): array ]; return [ - 'item' => [new \stdClass(), 'item_query', $defaultContext + ['is_collection' => false, 'is_mutation' => false], false, ['normalized_item']], - 'collection without pagination' => [[new \stdClass(), new \stdClass()], 'collection_query', $defaultContext + ['is_collection' => true, 'is_mutation' => false], false, [['normalized_item'], ['normalized_item']]], - 'mutation' => [new \stdClass(), 'create', array_merge($defaultContext, ['args' => ['input' => ['clientMutationId' => 'clientMutationId']], 'is_collection' => false, 'is_mutation' => true]), false, ['shortName' => ['normalized_item'], 'clientMutationId' => 'clientMutationId']], - 'delete mutation' => [new \stdClass(), 'delete', array_merge($defaultContext, ['args' => ['input' => ['id' => 4]], 'is_collection' => false, 'is_mutation' => true]), false, ['shortName' => ['id' => 4], 'clientMutationId' => null]], + 'item' => [new \stdClass(), 'item_query', $defaultContext + ['is_collection' => false, 'is_mutation' => false, 'is_subscription' => false], false, ['normalized_item']], + 'collection without pagination' => [[new \stdClass(), new \stdClass()], 'collection_query', $defaultContext + ['is_collection' => true, 'is_mutation' => false, 'is_subscription' => false], false, [['normalized_item'], ['normalized_item']]], + 'mutation' => [new \stdClass(), 'create', array_merge($defaultContext, ['args' => ['input' => ['clientMutationId' => 'clientMutationId']], 'is_collection' => false, 'is_mutation' => true, 'is_subscription' => false]), false, ['shortName' => ['normalized_item'], 'clientMutationId' => 'clientMutationId']], + 'delete mutation' => [new \stdClass(), 'delete', array_merge($defaultContext, ['args' => ['input' => ['id' => '/iri/4']], 'is_collection' => false, 'is_mutation' => true, 'is_subscription' => false]), false, ['shortName' => ['id' => '/iri/4'], 'clientMutationId' => null]], + 'subscription' => [new \stdClass(), 'update', array_merge($defaultContext, ['args' => ['input' => ['clientSubscriptionId' => 'clientSubscriptionId']], 'is_collection' => false, 'is_mutation' => false, 'is_subscription' => true]), false, ['shortName' => ['normalized_item'], 'clientSubscriptionId' => 'clientSubscriptionId']], ]; } @@ -116,6 +118,7 @@ public function testApplyCollectionWithPagination(iterable $collection, array $a $context = [ 'is_collection' => true, 'is_mutation' => false, + 'is_subscription' => false, 'args' => $args, 'info' => $this->prophesize(ResolveInfo::class)->reveal(), ]; @@ -154,7 +157,7 @@ public function testApplyBadNormalizedData(): void { $operationName = 'item_query'; $resourceClass = 'myResource'; - $context = ['is_collection' => false, 'is_mutation' => false, 'args' => [], 'info' => $this->prophesize(ResolveInfo::class)->reveal()]; + $context = ['is_collection' => false, 'is_mutation' => false, 'is_subscription' => false, 'args' => [], 'info' => $this->prophesize(ResolveInfo::class)->reveal()]; $this->resourceMetadataFactoryProphecy->create($resourceClass)->willReturn(new ResourceMetadata()); $normalizationContext = ['normalization' => true]; diff --git a/tests/GraphQl/Resolver/Util/IdentifierTraitTest.php b/tests/GraphQl/Resolver/Util/IdentifierTraitTest.php new file mode 100644 index 00000000000..b32b6209a56 --- /dev/null +++ b/tests/GraphQl/Resolver/Util/IdentifierTraitTest.php @@ -0,0 +1,53 @@ + + * + * 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\Tests\GraphQl\Resolver\Util; + +use ApiPlatform\Core\GraphQl\Resolver\Util\IdentifierTrait; +use PHPUnit\Framework\TestCase; + +/** + * @author Alan Poulain + */ +class IdentifierTraitTest extends TestCase +{ + private function getIdentifierTraitImplementation() + { + return new class() { + use IdentifierTrait { + IdentifierTrait::getIdentifierFromContext as public; + } + }; + } + + public function testGetIdentifierFromQueryContext(): void + { + $identifierTrait = $this->getIdentifierTraitImplementation(); + + $this->assertEquals('foo', $identifierTrait->getIdentifierFromContext(['args' => ['id' => 'foo'], 'is_collection' => false, 'is_mutation' => false, 'is_subscription' => false])); + } + + public function testGetIdentifierFromMutationContext(): void + { + $identifierTrait = $this->getIdentifierTraitImplementation(); + + $this->assertEquals('foo', $identifierTrait->getIdentifierFromContext(['args' => ['input' => ['id' => 'foo']], 'is_collection' => false, 'is_mutation' => true, 'is_subscription' => false])); + } + + public function testGetIdentifierFromSubscriptionContext(): void + { + $identifierTrait = $this->getIdentifierTraitImplementation(); + + $this->assertEquals('foo', $identifierTrait->getIdentifierFromContext(['args' => ['input' => ['id' => 'foo']], 'is_collection' => false, 'is_mutation' => false, 'is_subscription' => true])); + } +} diff --git a/tests/GraphQl/Serializer/ItemNormalizerTest.php b/tests/GraphQl/Serializer/ItemNormalizerTest.php index 6c82eec7183..13363bee76a 100644 --- a/tests/GraphQl/Serializer/ItemNormalizerTest.php +++ b/tests/GraphQl/Serializer/ItemNormalizerTest.php @@ -123,6 +123,57 @@ public function testNormalize() ])); } + public function testNormalizeNoResolverData(): void + { + $dummy = new Dummy(); + $dummy->setName('hello'); + + $propertyNameCollection = new PropertyNameCollection(['name']); + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(Dummy::class, [])->willReturn($propertyNameCollection); + + $propertyMetadata = new PropertyMetadata(null, null, true); + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', [])->willReturn($propertyMetadata); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getIriFromItem($dummy)->willReturn('/dummies/1'); + + $identifiersExtractorProphecy = $this->prophesize(IdentifiersExtractorInterface::class); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass($dummy, null)->willReturn(Dummy::class); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->willImplement(NormalizerInterface::class); + $serializerProphecy->normalize('hello', ItemNormalizer::FORMAT, Argument::type('array'))->willReturn('hello'); + + $normalizer = new ItemNormalizer( + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $identifiersExtractorProphecy->reveal(), + $resourceClassResolverProphecy->reveal(), + null, + null, + null, + null, + false, + null, + [], + null + ); + $normalizer->setSerializer($serializerProphecy->reveal()); + + $expected = [ + 'name' => 'hello', + ]; + $this->assertEquals($expected, $normalizer->normalize($dummy, ItemNormalizer::FORMAT, [ + 'resources' => [], + 'no_resolver_data' => true, + ])); + } + public function testDenormalize() { $context = ['resource_class' => Dummy::class, 'api_allow_update' => true]; diff --git a/tests/GraphQl/Serializer/SerializerContextBuilderTest.php b/tests/GraphQl/Serializer/SerializerContextBuilderTest.php index fc6e40b5ccf..277a696f6fb 100644 --- a/tests/GraphQl/Serializer/SerializerContextBuilderTest.php +++ b/tests/GraphQl/Serializer/SerializerContextBuilderTest.php @@ -45,15 +45,21 @@ protected function setUp(): void /** * @dataProvider createNormalizationContextProvider */ - public function testCreateNormalizationContext(?string $resourceClass, string $operationName, array $fields, array $expectedContext, bool $isMutation, ?string $expectedExceptionClass = null, ?string $expectedExceptionMessage = null): void + public function testCreateNormalizationContext(?string $resourceClass, string $operationName, array $fields, bool $isMutation, bool $isSubscription, bool $noInfo, array $expectedContext, ?string $expectedExceptionClass = null, ?string $expectedExceptionMessage = null): void { - $resolveInfoProphecy = $this->prophesize(ResolveInfo::class); - $resolveInfoProphecy->getFieldSelection(PHP_INT_MAX)->willReturn($fields); $resolverContext = [ - 'info' => $resolveInfoProphecy->reveal(), 'is_mutation' => $isMutation, + 'is_subscription' => $isSubscription, ]; + if ($noInfo) { + $resolverContext['fields'] = $fields; + } else { + $resolveInfoProphecy = $this->prophesize(ResolveInfo::class); + $resolveInfoProphecy->getFieldSelection(PHP_INT_MAX)->willReturn($fields); + $resolverContext['info'] = $resolveInfoProphecy->reveal(); + } + $this->resourceMetadataFactoryProphecy->create($resourceClass)->willReturn( (new ResourceMetadata('shortName')) ->withGraphql([ @@ -82,6 +88,9 @@ public function createNormalizationContextProvider(): array $resourceClass = 'myResource', $operationName = 'item_query', ['_id' => 3, 'field' => 'foo'], + false, + false, + false, [ 'groups' => ['normalization_group'], 'resource_class' => $resourceClass, @@ -93,12 +102,14 @@ public function createNormalizationContextProvider(): array 'input' => ['class' => 'inputClass'], 'output' => ['class' => 'outputClass'], ], - false, ], 'nominal collection' => [ $resourceClass = 'myResource', $operationName = 'collection_query', ['edges' => ['node' => ['nodeField' => 'baz']]], + false, + false, + false, [ 'groups' => ['normalization_group'], 'resource_class' => $resourceClass, @@ -109,12 +120,14 @@ public function createNormalizationContextProvider(): array 'input' => ['class' => 'inputClass'], 'output' => ['class' => 'outputClass'], ], - false, ], 'no resource class' => [ $resourceClass = null, $operationName = 'item_query', ['related' => ['_id' => 9]], + false, + false, + false, [ 'resource_class' => $resourceClass, 'graphql_operation_name' => $operationName, @@ -122,12 +135,14 @@ public function createNormalizationContextProvider(): array 'related' => ['id' => 9], ], ], - false, ], 'mutation' => [ $resourceClass = 'myResource', $operationName = 'create', ['shortName' => ['_id' => 7, 'related' => ['field' => 'bar']]], + true, + false, + false, [ 'groups' => ['normalization_group'], 'resource_class' => $resourceClass, @@ -139,16 +154,37 @@ public function createNormalizationContextProvider(): array 'input' => ['class' => 'inputClass'], 'output' => ['class' => 'outputClass'], ], - true, ], 'mutation without resource class' => [ $resourceClass = null, $operationName = 'create', ['shortName' => ['_id' => 7, 'related' => ['field' => 'bar']]], - [], true, + false, + false, + [], \LogicException::class, - 'ResourceMetadata should always exist for a mutation.', + 'ResourceMetadata should always exist for a mutation or a subscription.', + ], + 'subscription (using fields in context)' => [ + $resourceClass = 'myResource', + $operationName = 'update', + ['shortName' => ['_id' => 7, 'related' => ['field' => 'bar']]], + false, + true, + true, + [ + 'groups' => ['normalization_group'], + 'resource_class' => $resourceClass, + 'graphql_operation_name' => $operationName, + 'attributes' => [ + 'id' => 7, + 'related' => ['field' => 'bar'], + ], + 'no_resolver_data' => true, + 'input' => ['class' => 'inputClass'], + 'output' => ['class' => 'outputClass'], + ], ], ]; } diff --git a/tests/GraphQl/Subscription/MercureSubscriptionIriGeneratorTest.php b/tests/GraphQl/Subscription/MercureSubscriptionIriGeneratorTest.php new file mode 100644 index 00000000000..f2d1d835160 --- /dev/null +++ b/tests/GraphQl/Subscription/MercureSubscriptionIriGeneratorTest.php @@ -0,0 +1,55 @@ + + * + * 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\Tests\GraphQl\Subscription; + +use ApiPlatform\Core\GraphQl\Subscription\MercureSubscriptionIriGenerator; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Routing\RequestContext; + +/** + * @author Alan Poulain + */ +class MercureSubscriptionIriGeneratorTest extends TestCase +{ + private $requestContext; + private $hubUrl; + private $mercureSubscriptionIriGenerator; + + /** + * {@inheritdoc} + */ + protected function setUp(): void + { + $this->requestContext = new RequestContext('', 'GET', 'example.com'); + $this->hubUrl = 'https://demo.mercure.rocks/hub'; + $this->mercureSubscriptionIriGenerator = new MercureSubscriptionIriGenerator($this->requestContext, $this->hubUrl); + } + + public function testGenerateTopicIri(): void + { + $this->assertSame('http://example.com/subscriptions/subscription-id', $this->mercureSubscriptionIriGenerator->generateTopicIri('subscription-id')); + } + + public function testGenerateDefaultTopicIri(): void + { + $mercureSubscriptionIriGenerator = new MercureSubscriptionIriGenerator(new RequestContext('', 'GET', '', ''), $this->hubUrl); + + $this->assertSame('https://api-platform.com/subscriptions/subscription-id', $mercureSubscriptionIriGenerator->generateTopicIri('subscription-id')); + } + + public function testGenerateMercureUrl(): void + { + $this->assertSame("$this->hubUrl?topic=http://example.com/subscriptions/subscription-id", $this->mercureSubscriptionIriGenerator->generateMercureUrl('subscription-id')); + } +} diff --git a/tests/GraphQl/Subscription/SubscriptionIdentifierGeneratorTest.php b/tests/GraphQl/Subscription/SubscriptionIdentifierGeneratorTest.php new file mode 100644 index 00000000000..9dcf85d8616 --- /dev/null +++ b/tests/GraphQl/Subscription/SubscriptionIdentifierGeneratorTest.php @@ -0,0 +1,95 @@ + + * + * 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\Tests\GraphQl\Subscription; + +use ApiPlatform\Core\GraphQl\Subscription\SubscriptionIdentifierGenerator; +use PHPUnit\Framework\TestCase; + +/** + * @author Alan Poulain + */ +class SubscriptionIdentifierGeneratorTest extends TestCase +{ + private $subscriptionIdentifierGenerator; + + /** + * {@inheritdoc} + */ + protected function setUp(): void + { + $this->subscriptionIdentifierGenerator = new SubscriptionIdentifierGenerator(); + } + + public function testGenerateSubscriptionIdentifier(): void + { + $this->assertSame('bf861b4e0edd7766ff61da90c60fdceef2618b595a3628901921d4d8eca555d0', $this->subscriptionIdentifierGenerator->generateSubscriptionIdentifier([ + 'dummyMercure' => [ + 'id' => true, + 'name' => true, + 'relatedDummy' => [ + 'name' => true, + ], + ], + ])); + } + + public function testGenerateSubscriptionIdentifierFieldsNotIncluded(): void + { + $subscriptionId = $this->subscriptionIdentifierGenerator->generateSubscriptionIdentifier([ + 'dummyMercure' => [ + 'id' => true, + 'name' => true, + 'relatedDummy' => [ + 'name' => true, + ], + ], + ]); + + $subscriptionId2 = $this->subscriptionIdentifierGenerator->generateSubscriptionIdentifier([ + 'dummyMercure' => [ + 'id' => true, + 'name' => true, + 'relatedDummy' => [ + 'name' => true, + ], + ], + 'mercureUrl' => true, + 'clientSubscriptionId' => true, + ]); + + $this->assertSame($subscriptionId, $subscriptionId2); + } + + public function testDifferentGeneratedSubscriptionIdentifiers(): void + { + $subscriptionId = $this->subscriptionIdentifierGenerator->generateSubscriptionIdentifier([ + 'dummyMercure' => [ + 'id' => true, + 'name' => true, + 'relatedDummy' => [ + 'name' => true, + ], + ], + ]); + + $this->assertNotSame($subscriptionId, $this->subscriptionIdentifierGenerator->generateSubscriptionIdentifier([ + 'dummyMercure' => [ + 'id' => true, + 'relatedDummy' => [ + 'name' => true, + ], + ], + ])); + } +} diff --git a/tests/GraphQl/Subscription/SubscriptionManagerTest.php b/tests/GraphQl/Subscription/SubscriptionManagerTest.php new file mode 100644 index 00000000000..5b35497c1f7 --- /dev/null +++ b/tests/GraphQl/Subscription/SubscriptionManagerTest.php @@ -0,0 +1,194 @@ + + * + * 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\Tests\GraphQl\Subscription; + +use ApiPlatform\Core\Api\IriConverterInterface; +use ApiPlatform\Core\GraphQl\Resolver\Stage\SerializeStageInterface; +use ApiPlatform\Core\GraphQl\Subscription\SubscriptionIdentifierGeneratorInterface; +use ApiPlatform\Core\GraphQl\Subscription\SubscriptionManager; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy; +use GraphQL\Type\Definition\ResolveInfo; +use PHPUnit\Framework\TestCase; +use Psr\Cache\CacheItemInterface; +use Psr\Cache\CacheItemPoolInterface; + +/** + * @author Alan Poulain + */ +class SubscriptionManagerTest extends TestCase +{ + private $subscriptionsCacheProphecy; + private $subscriptionIdentifierGeneratorProphecy; + private $serializeStageProphecy; + private $iriConverterProphecy; + private $subscriptionManager; + + /** + * {@inheritdoc} + */ + protected function setUp(): void + { + $this->subscriptionsCacheProphecy = $this->prophesize(CacheItemPoolInterface::class); + $this->subscriptionIdentifierGeneratorProphecy = $this->prophesize(SubscriptionIdentifierGeneratorInterface::class); + $this->serializeStageProphecy = $this->prophesize(SerializeStageInterface::class); + $this->iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $this->subscriptionManager = new SubscriptionManager($this->subscriptionsCacheProphecy->reveal(), $this->subscriptionIdentifierGeneratorProphecy->reveal(), $this->serializeStageProphecy->reveal(), $this->iriConverterProphecy->reveal()); + } + + public function testRetrieveSubscriptionIdNoIdentifier(): void + { + $info = $this->prophesize(ResolveInfo::class); + $info->getFieldSelection(PHP_INT_MAX)->willReturn([]); + + $context = ['args' => [], 'info' => $info->reveal(), 'is_collection' => false, 'is_mutation' => false, 'is_subscription' => true]; + + $this->assertNull($this->subscriptionManager->retrieveSubscriptionId($context, null)); + } + + public function testRetrieveSubscriptionIdNoHit(): void + { + $infoProphecy = $this->prophesize(ResolveInfo::class); + $fields = ['fields']; + $infoProphecy->getFieldSelection(PHP_INT_MAX)->willReturn($fields); + + $context = ['args' => ['input' => ['id' => '/foos/34']], 'info' => $infoProphecy->reveal(), 'is_collection' => false, 'is_mutation' => false, 'is_subscription' => true]; + $result = ['result', 'clientSubscriptionId' => 'client-subscription-id']; + + $cacheItemProphecy = $this->prophesize(CacheItemInterface::class); + $cacheItemProphecy->isHit()->willReturn(false); + $subscriptionId = 'subscriptionId'; + $this->subscriptionIdentifierGeneratorProphecy->generateSubscriptionIdentifier($fields)->willReturn($subscriptionId); + $cacheItemProphecy->set([[$subscriptionId, $fields, ['result']]])->shouldBeCalled(); + $this->subscriptionsCacheProphecy->getItem('_foos_34')->shouldBeCalled()->willReturn($cacheItemProphecy->reveal()); + $this->subscriptionsCacheProphecy->save($cacheItemProphecy->reveal())->shouldBeCalled(); + + $this->assertSame($subscriptionId, $this->subscriptionManager->retrieveSubscriptionId($context, $result)); + } + + public function testRetrieveSubscriptionIdHitNotCached(): void + { + $infoProphecy = $this->prophesize(ResolveInfo::class); + $fields = ['fields']; + $infoProphecy->getFieldSelection(PHP_INT_MAX)->willReturn($fields); + + $context = ['args' => ['input' => ['id' => '/foos/34']], 'info' => $infoProphecy->reveal(), 'is_collection' => false, 'is_mutation' => false, 'is_subscription' => true]; + $result = ['result', 'clientSubscriptionId' => 'client-subscription-id']; + + $cacheItemProphecy = $this->prophesize(CacheItemInterface::class); + $cacheItemProphecy->isHit()->willReturn(true); + $cachedSubscriptions = [ + ['subscriptionIdFoo', ['fieldsFoo'], ['resultFoo']], + ['subscriptionIdBar', ['fieldsBar'], ['resultBar']], + ]; + $cacheItemProphecy->get()->willReturn($cachedSubscriptions); + $subscriptionId = 'subscriptionId'; + $this->subscriptionIdentifierGeneratorProphecy->generateSubscriptionIdentifier($fields)->willReturn($subscriptionId); + $cacheItemProphecy->set(array_merge($cachedSubscriptions, [[$subscriptionId, $fields, ['result']]]))->shouldBeCalled(); + $this->subscriptionsCacheProphecy->getItem('_foos_34')->shouldBeCalled()->willReturn($cacheItemProphecy->reveal()); + $this->subscriptionsCacheProphecy->save($cacheItemProphecy->reveal())->shouldBeCalled(); + + $this->assertSame($subscriptionId, $this->subscriptionManager->retrieveSubscriptionId($context, $result)); + } + + public function testRetrieveSubscriptionIdHitCached(): void + { + $infoProphecy = $this->prophesize(ResolveInfo::class); + $fields = ['fieldsBar']; + $infoProphecy->getFieldSelection(PHP_INT_MAX)->willReturn($fields); + + $context = ['args' => ['input' => ['id' => '/foos/34']], 'info' => $infoProphecy->reveal(), 'is_collection' => false, 'is_mutation' => false, 'is_subscription' => true]; + $result = ['result']; + + $cacheItemProphecy = $this->prophesize(CacheItemInterface::class); + $cacheItemProphecy->isHit()->willReturn(true); + $cacheItemProphecy->get()->willReturn([ + ['subscriptionIdFoo', ['fieldsFoo'], ['resultFoo']], + ['subscriptionIdBar', ['fieldsBar'], ['resultBar']], + ]); + $this->subscriptionIdentifierGeneratorProphecy->generateSubscriptionIdentifier($fields)->shouldNotBeCalled(); + $this->subscriptionsCacheProphecy->getItem('_foos_34')->shouldBeCalled()->willReturn($cacheItemProphecy->reveal()); + + $this->assertSame('subscriptionIdBar', $this->subscriptionManager->retrieveSubscriptionId($context, $result)); + } + + public function testRetrieveSubscriptionIdHitCachedDifferentFieldsOrder(): void + { + $infoProphecy = $this->prophesize(ResolveInfo::class); + $fields = [ + 'third' => true, + 'second' => [ + 'second' => true, + 'third' => true, + 'first' => true, + ], + 'first' => true, + ]; + $infoProphecy->getFieldSelection(PHP_INT_MAX)->willReturn($fields); + + $context = ['args' => ['input' => ['id' => '/foos/34']], 'info' => $infoProphecy->reveal(), 'is_collection' => false, 'is_mutation' => false, 'is_subscription' => true]; + $result = ['result']; + + $cacheItemProphecy = $this->prophesize(CacheItemInterface::class); + $cacheItemProphecy->isHit()->willReturn(true); + $cacheItemProphecy->get()->willReturn([ + ['subscriptionIdFoo', [ + 'first' => true, + 'second' => [ + 'first' => true, + 'second' => true, + 'third' => true, + ], + 'third' => true, + ], ['resultFoo']], + ['subscriptionIdBar', ['fieldsBar'], ['resultBar']], + ]); + $this->subscriptionIdentifierGeneratorProphecy->generateSubscriptionIdentifier($fields)->shouldNotBeCalled(); + $this->subscriptionsCacheProphecy->getItem('_foos_34')->shouldBeCalled()->willReturn($cacheItemProphecy->reveal()); + + $this->assertSame('subscriptionIdFoo', $this->subscriptionManager->retrieveSubscriptionId($context, $result)); + } + + public function testGetPushPayloadsNoHit(): void + { + $object = new Dummy(); + + $this->iriConverterProphecy->getIriFromItem($object)->willReturn('/dummies/2'); + + $cacheItemProphecy = $this->prophesize(CacheItemInterface::class); + $cacheItemProphecy->isHit()->willReturn(false); + $this->subscriptionsCacheProphecy->getItem('_dummies_2')->willReturn($cacheItemProphecy->reveal()); + + $this->assertSame([], $this->subscriptionManager->getPushPayloads($object)); + } + + public function testGetPushPayloadsHit(): void + { + $object = new Dummy(); + + $this->iriConverterProphecy->getIriFromItem($object)->willReturn('/dummies/2'); + + $cacheItemProphecy = $this->prophesize(CacheItemInterface::class); + $cacheItemProphecy->isHit()->willReturn(true); + $cacheItemProphecy->get()->willReturn([ + ['subscriptionIdFoo', ['fieldsFoo'], ['resultFoo']], + ['subscriptionIdBar', ['fieldsBar'], ['resultBar']], + ]); + $this->subscriptionsCacheProphecy->getItem('_dummies_2')->willReturn($cacheItemProphecy->reveal()); + + $this->serializeStageProphecy->__invoke($object, Dummy::class, 'update', ['fields' => ['fieldsFoo'], 'is_collection' => false, 'is_mutation' => false, 'is_subscription' => true])->willReturn(['newResultFoo', 'clientSubscriptionId' => 'client-subscription-id']); + $this->serializeStageProphecy->__invoke($object, Dummy::class, 'update', ['fields' => ['fieldsBar'], 'is_collection' => false, 'is_mutation' => false, 'is_subscription' => true])->willReturn(['resultBar', 'clientSubscriptionId' => 'client-subscription-id']); + + $this->assertSame([['subscriptionIdFoo', ['newResultFoo']]], $this->subscriptionManager->getPushPayloads($object)); + } +} diff --git a/tests/GraphQl/Type/FieldsBuilderTest.php b/tests/GraphQl/Type/FieldsBuilderTest.php index 5ae8f81fd29..eceba93f963 100644 --- a/tests/GraphQl/Type/FieldsBuilderTest.php +++ b/tests/GraphQl/Type/FieldsBuilderTest.php @@ -71,6 +71,9 @@ class FieldsBuilderTest extends TestCase /** @var ObjectProphecy */ private $itemMutationResolverFactoryProphecy; + /** @var ObjectProphecy */ + private $itemSubscriptionResolverFactoryProphecy; + /** @var ObjectProphecy */ private $filterLocatorProphecy; @@ -91,8 +94,9 @@ protected function setUp(): void $this->itemResolverFactoryProphecy = $this->prophesize(ResolverFactoryInterface::class); $this->collectionResolverFactoryProphecy = $this->prophesize(ResolverFactoryInterface::class); $this->itemMutationResolverFactoryProphecy = $this->prophesize(ResolverFactoryInterface::class); + $this->itemSubscriptionResolverFactoryProphecy = $this->prophesize(ResolverFactoryInterface::class); $this->filterLocatorProphecy = $this->prophesize(ContainerInterface::class); - $this->fieldsBuilder = new FieldsBuilder($this->propertyNameCollectionFactoryProphecy->reveal(), $this->propertyMetadataFactoryProphecy->reveal(), $this->resourceMetadataFactoryProphecy->reveal(), $this->typesContainerProphecy->reveal(), $this->typeBuilderProphecy->reveal(), $this->typeConverterProphecy->reveal(), $this->itemResolverFactoryProphecy->reveal(), $this->collectionResolverFactoryProphecy->reveal(), $this->itemMutationResolverFactoryProphecy->reveal(), $this->filterLocatorProphecy->reveal(), new Pagination($this->resourceMetadataFactoryProphecy->reveal()), new CustomConverter(), '__'); + $this->fieldsBuilder = new FieldsBuilder($this->propertyNameCollectionFactoryProphecy->reveal(), $this->propertyMetadataFactoryProphecy->reveal(), $this->resourceMetadataFactoryProphecy->reveal(), $this->typesContainerProphecy->reveal(), $this->typeBuilderProphecy->reveal(), $this->typeConverterProphecy->reveal(), $this->itemResolverFactoryProphecy->reveal(), $this->collectionResolverFactoryProphecy->reveal(), $this->itemMutationResolverFactoryProphecy->reveal(), $this->itemSubscriptionResolverFactoryProphecy->reveal(), $this->filterLocatorProphecy->reveal(), new Pagination($this->resourceMetadataFactoryProphecy->reveal()), new CustomConverter(), '__'); } public function testGetNodeQueryFields(): void @@ -124,7 +128,7 @@ public function testGetNodeQueryFields(): void */ public function testGetItemQueryFields(string $resourceClass, ResourceMetadata $resourceMetadata, string $queryName, array $configuration, ?GraphQLType $graphqlType, ?callable $resolver, array $expectedQueryFields): void { - $this->typeConverterProphecy->convertType(Argument::type(Type::class), false, $queryName, null, $resourceClass, $resourceClass, null, 0)->willReturn($graphqlType); + $this->typeConverterProphecy->convertType(Argument::type(Type::class), false, $queryName, null, null, $resourceClass, $resourceClass, null, 0)->willReturn($graphqlType); $this->typeConverterProphecy->resolveType(Argument::type('string'))->willReturn(GraphQLType::string()); $this->typeBuilderProphecy->isCollection(Argument::type(Type::class))->willReturn(false); $this->resourceMetadataFactoryProphecy->create($resourceClass)->willReturn($resourceMetadata); @@ -203,7 +207,7 @@ public function itemQueryFieldsProvider(): array */ public function testGetCollectionQueryFields(string $resourceClass, ResourceMetadata $resourceMetadata, string $queryName, array $configuration, ?GraphQLType $graphqlType, ?callable $resolver, array $expectedQueryFields): void { - $this->typeConverterProphecy->convertType(Argument::type(Type::class), false, $queryName, null, $resourceClass, $resourceClass, null, 0)->willReturn($graphqlType); + $this->typeConverterProphecy->convertType(Argument::type(Type::class), false, $queryName, null, null, $resourceClass, $resourceClass, null, 0)->willReturn($graphqlType); $this->typeConverterProphecy->resolveType(Argument::type('string'))->willReturn(GraphQLType::string()); $this->typeBuilderProphecy->isCollection(Argument::type(Type::class))->willReturn(true); $this->typeBuilderProphecy->getResourcePaginatedCollectionType($graphqlType, $resourceClass, $queryName)->willReturn($graphqlType); @@ -352,15 +356,14 @@ public function collectionQueryFieldsProvider(): array /** * @dataProvider mutationFieldsProvider */ - public function testGetMutationFields(string $resourceClass, ResourceMetadata $resourceMetadata, string $mutationName, GraphQLType $graphqlType, bool $isTypeCollection, ?callable $mutationResolver, ?callable $collectionResolver, array $expectedMutationFields): void + public function testGetMutationFields(string $resourceClass, ResourceMetadata $resourceMetadata, string $mutationName, GraphQLType $graphqlType, GraphQLType $inputGraphqlType, ?callable $mutationResolver, array $expectedMutationFields): void { - $this->typeConverterProphecy->convertType(Argument::type(Type::class), false, null, $mutationName, $resourceClass, $resourceClass, null, 0)->willReturn($graphqlType); - $this->typeConverterProphecy->convertType(Argument::type(Type::class), true, null, $mutationName, $resourceClass, $resourceClass, null, 0)->willReturn($graphqlType); - $this->typeBuilderProphecy->isCollection(Argument::type(Type::class))->willReturn($isTypeCollection); + $this->typeConverterProphecy->convertType(Argument::type(Type::class), false, null, $mutationName, null, $resourceClass, $resourceClass, null, 0)->willReturn($graphqlType); + $this->typeConverterProphecy->convertType(Argument::type(Type::class), true, null, $mutationName, null, $resourceClass, $resourceClass, null, 0)->willReturn($inputGraphqlType); + $this->typeBuilderProphecy->isCollection(Argument::type(Type::class))->willReturn(false); $this->typeBuilderProphecy->getResourcePaginatedCollectionType($graphqlType, $resourceClass, $mutationName)->willReturn($graphqlType); - $this->resourceMetadataFactoryProphecy->create($resourceClass)->willReturn(new ResourceMetadata()); - $this->collectionResolverFactoryProphecy->__invoke($resourceClass, $resourceClass, null)->willReturn($collectionResolver); - $this->itemMutationResolverFactoryProphecy->__invoke($resourceClass, null, $mutationName)->willReturn($mutationResolver); + $this->resourceMetadataFactoryProphecy->create($resourceClass)->willReturn($resourceMetadata); + $this->itemMutationResolverFactoryProphecy->__invoke($resourceClass, $resourceClass, $mutationName)->willReturn($mutationResolver); $mutationFields = $this->fieldsBuilder->getMutationFields($resourceClass, $resourceMetadata, $mutationName); @@ -370,15 +373,15 @@ public function testGetMutationFields(string $resourceClass, ResourceMetadata $r public function mutationFieldsProvider(): array { return [ - 'nominal case with deprecation reason' => ['resourceClass', (new ResourceMetadata('ShortName'))->withGraphql(['action' => ['deprecation_reason' => 'not useful']]), 'action', GraphQLType::string(), false, $mutationResolver = function () { - }, null, + 'nominal case with deprecation reason' => ['resourceClass', (new ResourceMetadata('ShortName'))->withGraphql(['action' => ['deprecation_reason' => 'not useful']]), 'action', $graphqlType = new ObjectType(['name' => 'mutation']), $inputGraphqlType = new ObjectType(['name' => 'input']), $mutationResolver = function () { + }, [ 'actionShortName' => [ - 'type' => GraphQLType::string(), + 'type' => $graphqlType, 'description' => 'Actions a ShortName.', 'args' => [ 'input' => [ - 'type' => GraphQLType::string(), + 'type' => $inputGraphqlType, 'description' => null, 'args' => [], 'resolve' => null, @@ -390,23 +393,48 @@ public function mutationFieldsProvider(): array ], ], ], - 'wrapped collection type' => ['resourceClass', new ResourceMetadata('ShortName'), 'action', $graphqlType = GraphQLType::listOf(new ObjectType(['name' => 'collection'])), true, null, $collectionResolver = function () { + ]; + } + + /** + * @dataProvider subscriptionFieldsProvider + */ + public function testGetSubscriptionFields(string $resourceClass, ResourceMetadata $resourceMetadata, string $subscriptionName, GraphQLType $graphqlType, GraphQLType $inputGraphqlType, ?callable $subscriptionResolver, array $expectedSubscriptionFields): void + { + $this->typeConverterProphecy->convertType(Argument::type(Type::class), false, null, null, $subscriptionName, $resourceClass, $resourceClass, null, 0)->willReturn($graphqlType); + $this->typeConverterProphecy->convertType(Argument::type(Type::class), true, null, null, $subscriptionName, $resourceClass, $resourceClass, null, 0)->willReturn($inputGraphqlType); + $this->typeBuilderProphecy->isCollection(Argument::type(Type::class))->willReturn(false); + $this->typeBuilderProphecy->getResourcePaginatedCollectionType($graphqlType, $resourceClass, $subscriptionName)->willReturn($graphqlType); + $this->resourceMetadataFactoryProphecy->create($resourceClass)->willReturn($resourceMetadata); + $this->itemSubscriptionResolverFactoryProphecy->__invoke($resourceClass, $resourceClass, $subscriptionName)->willReturn($subscriptionResolver); + + $subscriptionFields = $this->fieldsBuilder->getSubscriptionFields($resourceClass, $resourceMetadata, $subscriptionName); + + $this->assertEquals($expectedSubscriptionFields, $subscriptionFields); + } + + public function subscriptionFieldsProvider(): array + { + return [ + 'mercure not enabled' => ['resourceClass', new ResourceMetadata('ShortName'), 'action', new ObjectType(['name' => 'subscription']), new ObjectType(['name' => 'input']), null, [], + ], + 'nominal case with deprecation reason' => ['resourceClass', (new ResourceMetadata('ShortName'))->withAttributes(['mercure' => true])->withGraphql(['action' => ['deprecation_reason' => 'not useful']]), 'action', $graphqlType = new ObjectType(['name' => 'subscription']), $inputGraphqlType = new ObjectType(['name' => 'input']), $subscriptionResolver = function () { }, [ - 'actionShortName' => [ + 'actionShortNameSubscribe' => [ 'type' => $graphqlType, - 'description' => 'Actions a ShortName.', + 'description' => 'Subscribes to the action event of a ShortName.', 'args' => [ 'input' => [ - 'type' => GraphQLType::listOf($graphqlType), + 'type' => $inputGraphqlType, 'description' => null, 'args' => [], 'resolve' => null, - 'deprecationReason' => '', + 'deprecationReason' => 'not useful', ], ], - 'resolve' => $collectionResolver, - 'deprecationReason' => '', + 'resolve' => $subscriptionResolver, + 'deprecationReason' => 'not useful', ], ], ], @@ -416,24 +444,24 @@ public function mutationFieldsProvider(): array /** * @dataProvider resourceObjectTypeFieldsProvider */ - public function testGetResourceObjectTypeFields(string $resourceClass, ResourceMetadata $resourceMetadata, array $properties, bool $input, ?string $queryName, ?string $mutationName, ?array $ioMetadata, array $expectedResourceObjectTypeFields): void + public function testGetResourceObjectTypeFields(string $resourceClass, ResourceMetadata $resourceMetadata, array $properties, bool $input, ?string $queryName, ?string $mutationName, ?string $subscriptionName, ?array $ioMetadata, array $expectedResourceObjectTypeFields): void { $this->propertyNameCollectionFactoryProphecy->create($resourceClass)->willReturn(new PropertyNameCollection(array_keys($properties))); foreach ($properties as $propertyName => $propertyMetadata) { - $this->propertyMetadataFactoryProphecy->create($resourceClass, $propertyName, ['graphql_operation_name' => $queryName ?? $mutationName])->willReturn($propertyMetadata); - $this->propertyMetadataFactoryProphecy->create($resourceClass, $propertyName, ['graphql_operation_name' => $queryName ?? $mutationName])->willReturn($propertyMetadata); - $this->typeConverterProphecy->convertType(new Type(Type::BUILTIN_TYPE_NULL), Argument::type('bool'), $queryName, null, '', $resourceClass, $propertyName, 1)->willReturn(null); - $this->typeConverterProphecy->convertType(new Type(Type::BUILTIN_TYPE_CALLABLE), Argument::type('bool'), $queryName, null, '', $resourceClass, $propertyName, 1)->willReturn('NotRegisteredType'); - $this->typeConverterProphecy->convertType(Argument::type(Type::class), Argument::type('bool'), $queryName, null, '', $resourceClass, $propertyName, 1)->willReturn(GraphQLType::string()); - $this->typeConverterProphecy->convertType(Argument::type(Type::class), Argument::type('bool'), null, $mutationName, '', $resourceClass, $propertyName, 1)->willReturn(GraphQLType::string()); - $this->typeConverterProphecy->convertType(Argument::type(Type::class), true, null, $mutationName, 'subresourceClass', $propertyName, 1)->willReturn(GraphQLType::string()); + $this->propertyMetadataFactoryProphecy->create($resourceClass, $propertyName, ['graphql_operation_name' => $queryName ?? $mutationName ?? $subscriptionName])->willReturn($propertyMetadata); + $this->typeConverterProphecy->convertType(new Type(Type::BUILTIN_TYPE_NULL), Argument::type('bool'), $queryName, null, null, '', $resourceClass, $propertyName, 1)->willReturn(null); + $this->typeConverterProphecy->convertType(new Type(Type::BUILTIN_TYPE_CALLABLE), Argument::type('bool'), $queryName, null, null, '', $resourceClass, $propertyName, 1)->willReturn('NotRegisteredType'); + $this->typeConverterProphecy->convertType(Argument::type(Type::class), Argument::type('bool'), $queryName, null, null, '', $resourceClass, $propertyName, 1)->willReturn(GraphQLType::string()); + $this->typeConverterProphecy->convertType(Argument::type(Type::class), Argument::type('bool'), null, $mutationName, null, '', $resourceClass, $propertyName, 1)->willReturn(GraphQLType::string()); + $this->typeConverterProphecy->convertType(Argument::type(Type::class), Argument::type('bool'), null, null, $subscriptionName, '', $resourceClass, $propertyName, 1)->willReturn(GraphQLType::string()); + $this->typeConverterProphecy->convertType(Argument::type(Type::class), true, null, $mutationName, null, 'subresourceClass', $propertyName, 1)->willReturn(GraphQLType::string()); } $this->typesContainerProphecy->has('NotRegisteredType')->willReturn(false); $this->typesContainerProphecy->all()->willReturn([]); $this->typeBuilderProphecy->isCollection(Argument::type(Type::class))->willReturn(false); $this->resourceMetadataFactoryProphecy->create('subresourceClass')->willReturn(new ResourceMetadata()); - $resourceObjectTypeFields = $this->fieldsBuilder->getResourceObjectTypeFields($resourceClass, $resourceMetadata, $input, $queryName, $mutationName, 0, $ioMetadata); + $resourceObjectTypeFields = $this->fieldsBuilder->getResourceObjectTypeFields($resourceClass, $resourceMetadata, $input, $queryName, $mutationName, $subscriptionName, 0, $ioMetadata); $this->assertEquals($expectedResourceObjectTypeFields, $resourceObjectTypeFields); } @@ -448,7 +476,7 @@ public function resourceObjectTypeFieldsProvider(): array 'propertyNotReadable' => new PropertyMetadata(new Type(Type::BUILTIN_TYPE_BOOL), null, false, false), 'nameConverted' => new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING), null, true, false), ], - false, 'item_query', null, null, + false, 'item_query', null, null, null, [ 'id' => [ 'type' => GraphQLType::nonNull(GraphQLType::id()), @@ -474,7 +502,7 @@ public function resourceObjectTypeFieldsProvider(): array 'property' => new PropertyMetadata(), 'propertyBool' => new PropertyMetadata(new Type(Type::BUILTIN_TYPE_BOOL), null, false, false), ], - true, 'item_query', null, null, + true, 'item_query', null, null, null, [ 'id' => [ 'type' => GraphQLType::nonNull(GraphQLType::id()), @@ -494,7 +522,7 @@ public function resourceObjectTypeFieldsProvider(): array 'propertyBool' => new PropertyMetadata(new Type(Type::BUILTIN_TYPE_BOOL), null, false, true), 'propertyReadable' => new PropertyMetadata(new Type(Type::BUILTIN_TYPE_BOOL), null, true, true), ], - false, null, 'mutation', null, + false, null, 'mutation', null, null, [ 'id' => [ 'type' => GraphQLType::nonNull(GraphQLType::id()), @@ -515,7 +543,7 @@ public function resourceObjectTypeFieldsProvider(): array 'propertySubresource' => (new PropertyMetadata(new Type(Type::BUILTIN_TYPE_BOOL), null, false, true))->withSubresource(new SubresourceMetadata('subresourceClass')), 'id' => new PropertyMetadata(new Type(Type::BUILTIN_TYPE_INT), null, false, true), ], - true, null, 'mutation', null, + true, null, 'mutation', null, null, [ 'id' => [ 'type' => GraphQLType::nonNull(GraphQLType::id()), @@ -548,7 +576,7 @@ public function resourceObjectTypeFieldsProvider(): array [ 'propertyBool' => new PropertyMetadata(new Type(Type::BUILTIN_TYPE_BOOL), null, false, true), ], - true, null, 'delete', null, + true, null, 'delete', null, null, [ 'id' => [ 'type' => GraphQLType::nonNull(GraphQLType::id()), @@ -560,7 +588,7 @@ public function resourceObjectTypeFieldsProvider(): array [ 'propertyBool' => new PropertyMetadata(new Type(Type::BUILTIN_TYPE_BOOL), null, false, true), ], - true, null, 'create', null, + true, null, 'create', null, null, [ 'propertyBool' => [ 'type' => GraphQLType::nonNull(GraphQLType::string()), @@ -576,7 +604,7 @@ public function resourceObjectTypeFieldsProvider(): array [ 'propertyBool' => new PropertyMetadata(new Type(Type::BUILTIN_TYPE_BOOL), null, false, true), ], - true, null, 'update', null, + true, null, 'update', null, null, [ 'id' => [ 'type' => GraphQLType::nonNull(GraphQLType::id()), @@ -591,17 +619,52 @@ public function resourceObjectTypeFieldsProvider(): array 'clientMutationId' => GraphQLType::string(), ], ], + 'subscription non input' => ['resourceClass', new ResourceMetadata(), + [ + 'property' => new PropertyMetadata(), + 'propertyBool' => new PropertyMetadata(new Type(Type::BUILTIN_TYPE_BOOL), null, false, true), + 'propertyReadable' => new PropertyMetadata(new Type(Type::BUILTIN_TYPE_BOOL), null, true, true), + ], + false, null, null, 'subscription', null, + [ + 'id' => [ + 'type' => GraphQLType::nonNull(GraphQLType::id()), + ], + 'propertyReadable' => [ + 'type' => GraphQLType::nonNull(GraphQLType::string()), + 'description' => null, + 'args' => [], + 'resolve' => null, + 'deprecationReason' => '', + ], + ], + ], + 'subscription input' => ['resourceClass', new ResourceMetadata(), + [ + 'property' => new PropertyMetadata(), + 'propertyBool' => (new PropertyMetadata(new Type(Type::BUILTIN_TYPE_BOOL), 'propertyBool description', false, true))->withAttributes(['deprecation_reason' => 'not useful']), + 'propertySubresource' => (new PropertyMetadata(new Type(Type::BUILTIN_TYPE_BOOL), null, false, true))->withSubresource(new SubresourceMetadata('subresourceClass')), + 'id' => new PropertyMetadata(new Type(Type::BUILTIN_TYPE_INT), null, false, true), + ], + true, null, null, 'subscription', null, + [ + 'id' => [ + 'type' => GraphQLType::nonNull(GraphQLType::id()), + ], + 'clientSubscriptionId' => GraphQLType::string(), + ], + ], 'null io metadata non input' => ['resourceClass', new ResourceMetadata(), [ 'propertyBool' => new PropertyMetadata(new Type(Type::BUILTIN_TYPE_BOOL), null, false, true), ], - false, null, 'update', ['class' => null], [], + false, null, 'update', null, ['class' => null], [], ], 'null io metadata input' => ['resourceClass', new ResourceMetadata(), [ 'propertyBool' => new PropertyMetadata(new Type(Type::BUILTIN_TYPE_BOOL), null, false, true), ], - true, null, 'update', ['class' => null], + true, null, 'update', null, ['class' => null], [ 'clientMutationId' => GraphQLType::string(), ], @@ -611,7 +674,7 @@ public function resourceObjectTypeFieldsProvider(): array 'propertyInvalidType' => new PropertyMetadata(new Type(Type::BUILTIN_TYPE_NULL), null, true, false), 'propertyNotRegisteredType' => new PropertyMetadata(new Type(Type::BUILTIN_TYPE_CALLABLE), null, true, false), ], - false, 'item_query', null, null, + false, 'item_query', null, null, null, [ 'id' => [ 'type' => GraphQLType::nonNull(GraphQLType::id()), diff --git a/tests/GraphQl/Type/SchemaBuilderTest.php b/tests/GraphQl/Type/SchemaBuilderTest.php index 919561aa07f..d6bea8a7445 100644 --- a/tests/GraphQl/Type/SchemaBuilderTest.php +++ b/tests/GraphQl/Type/SchemaBuilderTest.php @@ -65,7 +65,7 @@ protected function setUp(): void /** * @dataProvider schemaProvider */ - public function testGetSchema(string $resourceClass, ResourceMetadata $resourceMetadata, ObjectType $expectedQueryType, ?ObjectType $expectedMutationType): void + public function testGetSchema(string $resourceClass, ResourceMetadata $resourceMetadata, ObjectType $expectedQueryType, ?ObjectType $expectedMutationType, ?ObjectType $expectedSubscriptionType): void { $type = $this->prophesize(GraphQLType::class)->reveal(); $type->name = 'MyType'; @@ -81,6 +81,8 @@ public function testGetSchema(string $resourceClass, ResourceMetadata $resourceM $this->fieldsBuilderProphecy->getItemQueryFields($resourceClass, $resourceMetadata, 'custom_item_query', ['item_query' => 'item_query_resolver'])->willReturn(['custom_item_query' => ['custom_item_query_fields']]); $this->fieldsBuilderProphecy->getCollectionQueryFields($resourceClass, $resourceMetadata, 'custom_collection_query', ['collection_query' => 'collection_query_resolver'])->willReturn(['custom_collection_query' => ['custom_collection_query_fields']]); $this->fieldsBuilderProphecy->getMutationFields($resourceClass, $resourceMetadata, 'mutation')->willReturn(['mutation' => ['mutation_fields']]); + $this->fieldsBuilderProphecy->getMutationFields($resourceClass, $resourceMetadata, 'update')->willReturn(['mutation' => ['mutation_fields']]); + $this->fieldsBuilderProphecy->getSubscriptionFields($resourceClass, $resourceMetadata, 'update')->willReturn(['subscription' => ['subscription_fields']]); $this->resourceNameCollectionFactoryProphecy->create()->willReturn(new ResourceNameCollection([$resourceClass])); $this->resourceMetadataFactoryProphecy->create($resourceClass)->willReturn($resourceMetadata); @@ -88,6 +90,7 @@ public function testGetSchema(string $resourceClass, ResourceMetadata $resourceM $schema = $this->schemaBuilder->getSchema(); $this->assertEquals($expectedQueryType, $schema->getQueryType()); $this->assertEquals($expectedMutationType, $schema->getMutationType()); + $this->assertEquals($expectedSubscriptionType, $schema->getSubscriptionType()); $this->assertEquals($type, $schema->getType('MyType')); $this->assertEquals($typeFoo, $schema->getType('Foo')); } @@ -101,7 +104,7 @@ public function schemaProvider(): array 'fields' => [ 'node' => ['node_fields'], ], - ]), null, + ]), null, null, ], 'item query' => ['resourceClass', (new ResourceMetadata())->withGraphql(['item_query' => []]), new ObjectType([ @@ -110,7 +113,7 @@ public function schemaProvider(): array 'node' => ['node_fields'], 'query' => ['query_fields'], ], - ]), null, + ]), null, null, ], 'collection query' => ['resourceClass', (new ResourceMetadata())->withGraphql(['collection_query' => []]), new ObjectType([ @@ -119,7 +122,7 @@ public function schemaProvider(): array 'node' => ['node_fields'], 'query' => ['query_fields'], ], - ]), null, + ]), null, null, ], 'custom item query' => ['resourceClass', (new ResourceMetadata())->withGraphql(['custom_item_query' => ['item_query' => 'item_query_resolver']]), new ObjectType([ @@ -128,7 +131,7 @@ public function schemaProvider(): array 'node' => ['node_fields'], 'custom_item_query' => ['custom_item_query_fields'], ], - ]), null, + ]), null, null, ], 'custom collection query' => ['resourceClass', (new ResourceMetadata())->withGraphql(['custom_collection_query' => ['collection_query' => 'collection_query_resolver']]), new ObjectType([ @@ -137,7 +140,7 @@ public function schemaProvider(): array 'node' => ['node_fields'], 'custom_collection_query' => ['custom_collection_query_fields'], ], - ]), null, + ]), null, null, ], 'mutation' => ['resourceClass', (new ResourceMetadata())->withGraphql(['mutation' => []]), new ObjectType([ @@ -152,6 +155,27 @@ public function schemaProvider(): array 'mutation' => ['mutation_fields'], ], ]), + null, + ], + 'subscription' => ['resourceClass', (new ResourceMetadata())->withGraphql(['update' => []]), + new ObjectType([ + 'name' => 'Query', + 'fields' => [ + 'node' => ['node_fields'], + ], + ]), + new ObjectType([ + 'name' => 'Mutation', + 'fields' => [ + 'mutation' => ['mutation_fields'], + ], + ]), + new ObjectType([ + 'name' => 'Subscription', + 'fields' => [ + 'subscription' => ['subscription_fields'], + ], + ]), ], ]; } diff --git a/tests/GraphQl/Type/TypeBuilderTest.php b/tests/GraphQl/Type/TypeBuilderTest.php index 3272f25bd66..1721122fcda 100644 --- a/tests/GraphQl/Type/TypeBuilderTest.php +++ b/tests/GraphQl/Type/TypeBuilderTest.php @@ -81,7 +81,7 @@ public function testGetResourceObjectType(): void $this->typesContainerProphecy->set('Node', Argument::type(InterfaceType::class))->shouldBeCalled(); /** @var ObjectType $resourceObjectType */ - $resourceObjectType = $this->typeBuilder->getResourceObjectType('resourceClass', $resourceMetadata, false, 'item_query', null); + $resourceObjectType = $this->typeBuilder->getResourceObjectType('resourceClass', $resourceMetadata, false, 'item_query', null, null); $this->assertSame('shortName', $resourceObjectType->name); $this->assertSame('description', $resourceObjectType->description); $this->assertSame($this->defaultFieldResolver, $resourceObjectType->resolveFieldFn); @@ -89,7 +89,7 @@ public function testGetResourceObjectType(): void $this->assertArrayHasKey('fields', $resourceObjectType->config); $fieldsBuilderProphecy = $this->prophesize(FieldsBuilderInterface::class); - $fieldsBuilderProphecy->getResourceObjectTypeFields('resourceClass', $resourceMetadata, false, 'item_query', null, 0, null)->shouldBeCalled(); + $fieldsBuilderProphecy->getResourceObjectTypeFields('resourceClass', $resourceMetadata, false, 'item_query', null, null, 0, null)->shouldBeCalled(); $this->fieldsBuilderLocatorProphecy->get('api_platform.graphql.fields_builder')->shouldBeCalled()->willReturn($fieldsBuilderProphecy->reveal()); $resourceObjectType->config['fields'](); } @@ -104,7 +104,7 @@ public function testGetResourceObjectTypeOutputClass(): void $this->typesContainerProphecy->set('Node', Argument::type(InterfaceType::class))->shouldBeCalled(); /** @var ObjectType $resourceObjectType */ - $resourceObjectType = $this->typeBuilder->getResourceObjectType('resourceClass', $resourceMetadata, false, 'item_query', null); + $resourceObjectType = $this->typeBuilder->getResourceObjectType('resourceClass', $resourceMetadata, false, 'item_query', null, null); $this->assertSame('shortName', $resourceObjectType->name); $this->assertSame('description', $resourceObjectType->description); $this->assertSame($this->defaultFieldResolver, $resourceObjectType->resolveFieldFn); @@ -112,7 +112,7 @@ public function testGetResourceObjectTypeOutputClass(): void $this->assertArrayHasKey('fields', $resourceObjectType->config); $fieldsBuilderProphecy = $this->prophesize(FieldsBuilderInterface::class); - $fieldsBuilderProphecy->getResourceObjectTypeFields('outputClass', $resourceMetadata, false, 'item_query', null, 0, ['class' => 'outputClass'])->shouldBeCalled(); + $fieldsBuilderProphecy->getResourceObjectTypeFields('outputClass', $resourceMetadata, false, 'item_query', null, null, 0, ['class' => 'outputClass'])->shouldBeCalled(); $this->fieldsBuilderLocatorProphecy->get('api_platform.graphql.fields_builder')->shouldBeCalled()->willReturn($fieldsBuilderProphecy->reveal()); $resourceObjectType->config['fields'](); } @@ -133,7 +133,7 @@ public function testGetResourceObjectTypeQuerySerializationGroups(string $itemSe $this->typesContainerProphecy->set('Node', Argument::type(InterfaceType::class))->shouldBeCalled(); /** @var ObjectType $resourceObjectType */ - $resourceObjectType = $this->typeBuilder->getResourceObjectType('resourceClass', $resourceMetadata, false, $queryName, null); + $resourceObjectType = $this->typeBuilder->getResourceObjectType('resourceClass', $resourceMetadata, false, $queryName, null, null); $this->assertSame($shortName, $resourceObjectType->name); } @@ -170,7 +170,7 @@ public function testGetResourceObjectTypeInput(): void $this->typesContainerProphecy->set('Node', Argument::type(InterfaceType::class))->shouldBeCalled(); /** @var NonNull $resourceObjectType */ - $resourceObjectType = $this->typeBuilder->getResourceObjectType('resourceClass', $resourceMetadata, true, null, 'custom'); + $resourceObjectType = $this->typeBuilder->getResourceObjectType('resourceClass', $resourceMetadata, true, null, 'custom', null); /** @var InputObjectType $wrappedType */ $wrappedType = $resourceObjectType->getWrappedType(); $this->assertInstanceOf(InputObjectType::class, $wrappedType); @@ -180,7 +180,7 @@ public function testGetResourceObjectTypeInput(): void $this->assertArrayHasKey('fields', $wrappedType->config); $fieldsBuilderProphecy = $this->prophesize(FieldsBuilderInterface::class); - $fieldsBuilderProphecy->getResourceObjectTypeFields('resourceClass', $resourceMetadata, true, null, 'custom', 0, null)->shouldBeCalled(); + $fieldsBuilderProphecy->getResourceObjectTypeFields('resourceClass', $resourceMetadata, true, null, 'custom', null, 0, null)->shouldBeCalled(); $this->fieldsBuilderLocatorProphecy->get('api_platform.graphql.fields_builder')->shouldBeCalled()->willReturn($fieldsBuilderProphecy->reveal()); $wrappedType->config['fields'](); } @@ -195,7 +195,7 @@ public function testGetResourceObjectTypeCustomMutationInputArgs(): void $this->typesContainerProphecy->set('Node', Argument::type(InterfaceType::class))->shouldBeCalled(); /** @var NonNull $resourceObjectType */ - $resourceObjectType = $this->typeBuilder->getResourceObjectType('resourceClass', $resourceMetadata, true, null, 'custom'); + $resourceObjectType = $this->typeBuilder->getResourceObjectType('resourceClass', $resourceMetadata, true, null, 'custom', null); /** @var InputObjectType $wrappedType */ $wrappedType = $resourceObjectType->getWrappedType(); $this->assertInstanceOf(InputObjectType::class, $wrappedType); @@ -205,7 +205,7 @@ public function testGetResourceObjectTypeCustomMutationInputArgs(): void $this->assertArrayHasKey('fields', $wrappedType->config); $fieldsBuilderProphecy = $this->prophesize(FieldsBuilderInterface::class); - $fieldsBuilderProphecy->getResourceObjectTypeFields('resourceClass', $resourceMetadata, true, null, 'custom', 0, null) + $fieldsBuilderProphecy->getResourceObjectTypeFields('resourceClass', $resourceMetadata, true, null, 'custom', null, 0, null) ->shouldBeCalled()->willReturn(['clientMutationId' => GraphQLType::string()]); $fieldsBuilderProphecy->resolveResourceArgs([], 'custom', 'shortName')->shouldBeCalled(); $this->fieldsBuilderLocatorProphecy->get('api_platform.graphql.fields_builder')->shouldBeCalled()->willReturn($fieldsBuilderProphecy->reveal()); @@ -221,7 +221,7 @@ public function testGetResourceObjectTypeMutation(): void $this->typesContainerProphecy->set('Node', Argument::type(InterfaceType::class))->shouldBeCalled(); /** @var ObjectType $resourceObjectType */ - $resourceObjectType = $this->typeBuilder->getResourceObjectType('resourceClass', $resourceMetadata, false, null, 'create'); + $resourceObjectType = $this->typeBuilder->getResourceObjectType('resourceClass', $resourceMetadata, false, null, 'create', null); $this->assertSame('createShortNamePayload', $resourceObjectType->name); $this->assertSame('description', $resourceObjectType->description); $this->assertSame($this->defaultFieldResolver, $resourceObjectType->resolveFieldFn); @@ -252,7 +252,7 @@ public function testGetResourceObjectTypeMutationWrappedType(): void $this->typesContainerProphecy->set('Node', Argument::type(InterfaceType::class))->shouldBeCalled(); /** @var ObjectType $resourceObjectType */ - $resourceObjectType = $this->typeBuilder->getResourceObjectType('resourceClass', $resourceMetadata, false, null, 'create'); + $resourceObjectType = $this->typeBuilder->getResourceObjectType('resourceClass', $resourceMetadata, false, null, 'create', null); $this->assertSame('createShortNamePayload', $resourceObjectType->name); $this->assertSame('description', $resourceObjectType->description); $this->assertSame($this->defaultFieldResolver, $resourceObjectType->resolveFieldFn); @@ -278,7 +278,7 @@ public function testGetResourceObjectTypeMutationWrappedType(): void $this->assertArrayHasKey('fields', $wrappedType->config); $fieldsBuilderProphecy = $this->prophesize(FieldsBuilderInterface::class); - $fieldsBuilderProphecy->getResourceObjectTypeFields('resourceClass', $resourceMetadata, false, null, 'create', 0, null)->shouldBeCalled(); + $fieldsBuilderProphecy->getResourceObjectTypeFields('resourceClass', $resourceMetadata, false, null, 'create', null, 0, null)->shouldBeCalled(); $this->fieldsBuilderLocatorProphecy->get('api_platform.graphql.fields_builder')->shouldBeCalled()->willReturn($fieldsBuilderProphecy->reveal()); $wrappedType->config['fields'](); } @@ -292,7 +292,7 @@ public function testGetResourceObjectTypeMutationNested(): void $this->typesContainerProphecy->set('Node', Argument::type(InterfaceType::class))->shouldBeCalled(); /** @var ObjectType $resourceObjectType */ - $resourceObjectType = $this->typeBuilder->getResourceObjectType('resourceClass', $resourceMetadata, false, null, 'create', false, 1); + $resourceObjectType = $this->typeBuilder->getResourceObjectType('resourceClass', $resourceMetadata, false, null, 'create', null, false, 1); $this->assertSame('createShortNameNestedPayload', $resourceObjectType->name); $this->assertSame('description', $resourceObjectType->description); $this->assertSame($this->defaultFieldResolver, $resourceObjectType->resolveFieldFn); @@ -300,7 +300,103 @@ public function testGetResourceObjectTypeMutationNested(): void $this->assertArrayHasKey('fields', $resourceObjectType->config); $fieldsBuilderProphecy = $this->prophesize(FieldsBuilderInterface::class); - $fieldsBuilderProphecy->getResourceObjectTypeFields('resourceClass', $resourceMetadata, false, null, 'create', 1, null)->shouldBeCalled(); + $fieldsBuilderProphecy->getResourceObjectTypeFields('resourceClass', $resourceMetadata, false, null, 'create', null, 1, null)->shouldBeCalled(); + $this->fieldsBuilderLocatorProphecy->get('api_platform.graphql.fields_builder')->shouldBeCalled()->willReturn($fieldsBuilderProphecy->reveal()); + $resourceObjectType->config['fields'](); + } + + public function testGetResourceObjectTypeSubscription(): void + { + $resourceMetadata = (new ResourceMetadata('shortName', 'description'))->withAttributes(['mercure' => true]); + $this->typesContainerProphecy->has('updateShortNameSubscriptionPayload')->shouldBeCalled()->willReturn(false); + $this->typesContainerProphecy->set('updateShortNameSubscriptionPayload', Argument::type(ObjectType::class))->shouldBeCalled(); + $this->typesContainerProphecy->has('Node')->shouldBeCalled()->willReturn(false); + $this->typesContainerProphecy->set('Node', Argument::type(InterfaceType::class))->shouldBeCalled(); + + /** @var ObjectType $resourceObjectType */ + $resourceObjectType = $this->typeBuilder->getResourceObjectType('resourceClass', $resourceMetadata, false, null, null, 'update'); + $this->assertSame('updateShortNameSubscriptionPayload', $resourceObjectType->name); + $this->assertSame('description', $resourceObjectType->description); + $this->assertSame($this->defaultFieldResolver, $resourceObjectType->resolveFieldFn); + $this->assertArrayHasKey('interfaces', $resourceObjectType->config); + $this->assertSame([], $resourceObjectType->config['interfaces']); + $this->assertArrayHasKey('fields', $resourceObjectType->config); + + // Recursive call (not using wrapped type) + $this->typesContainerProphecy->has('shortName')->shouldBeCalled()->willReturn(false); + $this->typesContainerProphecy->set('shortName', Argument::type(ObjectType::class))->shouldBeCalled(); + + $fieldsType = $resourceObjectType->config['fields'](); + $this->assertArrayHasKey('shortName', $fieldsType); + $this->assertArrayHasKey('clientSubscriptionId', $fieldsType); + $this->assertArrayHasKey('mercureUrl', $fieldsType); + $this->assertSame(GraphQLType::string(), $fieldsType['clientSubscriptionId']); + $this->assertSame(GraphQLType::string(), $fieldsType['mercureUrl']); + } + + public function testGetResourceObjectTypeSubscriptionWrappedType(): void + { + $resourceMetadata = (new ResourceMetadata('shortName', 'description')) + ->withGraphql([ + 'item_query' => ['normalization_context' => ['groups' => ['item_query']]], + 'update' => ['normalization_context' => ['groups' => ['update']]], + ]); + $this->typesContainerProphecy->has('updateShortNameSubscriptionPayload')->shouldBeCalled()->willReturn(false); + $this->typesContainerProphecy->set('updateShortNameSubscriptionPayload', Argument::type(ObjectType::class))->shouldBeCalled(); + $this->typesContainerProphecy->has('Node')->shouldBeCalled()->willReturn(false); + $this->typesContainerProphecy->set('Node', Argument::type(InterfaceType::class))->shouldBeCalled(); + + /** @var ObjectType $resourceObjectType */ + $resourceObjectType = $this->typeBuilder->getResourceObjectType('resourceClass', $resourceMetadata, false, null, null, 'update'); + $this->assertSame('updateShortNameSubscriptionPayload', $resourceObjectType->name); + $this->assertSame('description', $resourceObjectType->description); + $this->assertSame($this->defaultFieldResolver, $resourceObjectType->resolveFieldFn); + $this->assertArrayHasKey('interfaces', $resourceObjectType->config); + $this->assertSame([], $resourceObjectType->config['interfaces']); + $this->assertArrayHasKey('fields', $resourceObjectType->config); + + // Recursive call (using wrapped type) + $this->typesContainerProphecy->has('updateShortNameSubscriptionPayloadData')->shouldBeCalled()->willReturn(false); + $this->typesContainerProphecy->set('updateShortNameSubscriptionPayloadData', Argument::type(ObjectType::class))->shouldBeCalled(); + + $fieldsType = $resourceObjectType->config['fields'](); + $this->assertArrayHasKey('shortName', $fieldsType); + $this->assertArrayHasKey('clientSubscriptionId', $fieldsType); + $this->assertArrayNotHasKey('mercureUrl', $fieldsType); + $this->assertSame(GraphQLType::string(), $fieldsType['clientSubscriptionId']); + + /** @var ObjectType $wrappedType */ + $wrappedType = $fieldsType['shortName']; + $this->assertSame('updateShortNameSubscriptionPayloadData', $wrappedType->name); + $this->assertSame('description', $wrappedType->description); + $this->assertSame($this->defaultFieldResolver, $wrappedType->resolveFieldFn); + $this->assertArrayHasKey('interfaces', $wrappedType->config); + $this->assertArrayHasKey('fields', $wrappedType->config); + + $fieldsBuilderProphecy = $this->prophesize(FieldsBuilderInterface::class); + $fieldsBuilderProphecy->getResourceObjectTypeFields('resourceClass', $resourceMetadata, false, null, null, 'update', 0, null)->shouldBeCalled(); + $this->fieldsBuilderLocatorProphecy->get('api_platform.graphql.fields_builder')->shouldBeCalled()->willReturn($fieldsBuilderProphecy->reveal()); + $wrappedType->config['fields'](); + } + + public function testGetResourceObjectTypeSubscriptionNested(): void + { + $resourceMetadata = (new ResourceMetadata('shortName', 'description'))->withAttributes(['mercure' => true]); + $this->typesContainerProphecy->has('updateShortNameSubscriptionNestedPayload')->shouldBeCalled()->willReturn(false); + $this->typesContainerProphecy->set('updateShortNameSubscriptionNestedPayload', Argument::type(ObjectType::class))->shouldBeCalled(); + $this->typesContainerProphecy->has('Node')->shouldBeCalled()->willReturn(false); + $this->typesContainerProphecy->set('Node', Argument::type(InterfaceType::class))->shouldBeCalled(); + + /** @var ObjectType $resourceObjectType */ + $resourceObjectType = $this->typeBuilder->getResourceObjectType('resourceClass', $resourceMetadata, false, null, null, 'update', false, 1); + $this->assertSame('updateShortNameSubscriptionNestedPayload', $resourceObjectType->name); + $this->assertSame('description', $resourceObjectType->description); + $this->assertSame($this->defaultFieldResolver, $resourceObjectType->resolveFieldFn); + $this->assertArrayHasKey('interfaces', $resourceObjectType->config); + $this->assertArrayHasKey('fields', $resourceObjectType->config); + + $fieldsBuilderProphecy = $this->prophesize(FieldsBuilderInterface::class); + $fieldsBuilderProphecy->getResourceObjectTypeFields('resourceClass', $resourceMetadata, false, null, null, 'update', 1, null)->shouldBeCalled(); $this->fieldsBuilderLocatorProphecy->get('api_platform.graphql.fields_builder')->shouldBeCalled()->willReturn($fieldsBuilderProphecy->reveal()); $resourceObjectType->config['fields'](); } diff --git a/tests/GraphQl/Type/TypeConverterTest.php b/tests/GraphQl/Type/TypeConverterTest.php index 69cb0eae469..af34e81465f 100644 --- a/tests/GraphQl/Type/TypeConverterTest.php +++ b/tests/GraphQl/Type/TypeConverterTest.php @@ -63,7 +63,7 @@ public function testConvertType(Type $type, bool $input, int $depth, $expectedGr { $this->typeBuilderProphecy->isCollection($type)->willReturn(false); - $graphqlType = $this->typeConverter->convertType($type, $input, null, null, 'resourceClass', 'rootClass', null, $depth); + $graphqlType = $this->typeConverter->convertType($type, $input, null, null, null, 'resourceClass', 'rootClass', null, $depth); $this->assertEquals($expectedGraphqlType, $graphqlType); } @@ -92,7 +92,7 @@ public function testConvertTypeNoGraphQlResourceMetadata(): void $this->typeBuilderProphecy->isCollection($type)->shouldBeCalled()->willReturn(false); $this->resourceMetadataFactoryProphecy->create('dummy')->shouldBeCalled()->willReturn(new ResourceMetadata()); - $graphqlType = $this->typeConverter->convertType($type, false, null, null, 'resourceClass', 'rootClass', null, 0); + $graphqlType = $this->typeConverter->convertType($type, false, null, null, null, 'resourceClass', 'rootClass', null, 0); $this->assertNull($graphqlType); } @@ -103,7 +103,7 @@ public function testConvertTypeResourceClassNotFound(): void $this->typeBuilderProphecy->isCollection($type)->shouldBeCalled()->willReturn(false); $this->resourceMetadataFactoryProphecy->create('dummy')->shouldBeCalled()->willThrow(new ResourceClassNotFoundException()); - $graphqlType = $this->typeConverter->convertType($type, false, null, null, 'resourceClass', 'rootClass', null, 0); + $graphqlType = $this->typeConverter->convertType($type, false, null, null, null, 'resourceClass', 'rootClass', null, 0); $this->assertNull($graphqlType); } @@ -115,9 +115,9 @@ public function testConvertTypeResource(): void $this->typeBuilderProphecy->isCollection($type)->shouldBeCalled()->willReturn(true); $this->resourceMetadataFactoryProphecy->create('dummyValue')->shouldBeCalled()->willReturn($graphqlResourceMetadata); - $this->typeBuilderProphecy->getResourceObjectType('dummyValue', $graphqlResourceMetadata, false, null, null, false, 0)->shouldBeCalled()->willReturn($expectedGraphqlType); + $this->typeBuilderProphecy->getResourceObjectType('dummyValue', $graphqlResourceMetadata, false, null, null, null, false, 0)->shouldBeCalled()->willReturn($expectedGraphqlType); - $graphqlType = $this->typeConverter->convertType($type, false, null, null, 'resourceClass', 'rootClass', null, 0); + $graphqlType = $this->typeConverter->convertType($type, false, null, null, null, 'resourceClass', 'rootClass', null, 0); $this->assertEquals($expectedGraphqlType, $graphqlType); } diff --git a/tests/Util/SortTraitTest.php b/tests/Util/SortTraitTest.php new file mode 100644 index 00000000000..4a924a13afa --- /dev/null +++ b/tests/Util/SortTraitTest.php @@ -0,0 +1,57 @@ + + * + * 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\Tests\Util; + +use ApiPlatform\Core\Util\SortTrait; +use PHPUnit\Framework\TestCase; + +/** + * @author Alan Poulain + */ +class SortTraitTest extends TestCase +{ + private function getSortTraitImplementation() + { + return new class() { + use SortTrait { + SortTrait::arrayRecursiveSort as public; + } + }; + } + + public function testArrayRecursiveSort(): void + { + $sortTrait = $this->getSortTraitImplementation(); + + $array = [ + 'second', + [ + 'second', + 'first', + ], + 'first', + ]; + + $sortTrait->arrayRecursiveSort($array, 'sort'); + + $this->assertSame([ + 'first', + 'second', + [ + 'first', + 'second', + ], + ], $array); + } +} From e778179a25ca80fc5a238da58a32a963882bf0af Mon Sep 17 00:00:00 2001 From: clementlefrancois Date: Mon, 3 Feb 2020 21:32:27 +0100 Subject: [PATCH 049/160] fix outdated pull request template link in contribution guide --- CONTRIBUTING.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dfdabf24917..7313c02026f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -49,8 +49,6 @@ that you did not make in your PR, you're doing it wrong. * Also don't forget to add a comment when you update a PR with a ping to [the maintainers](https://github.com/orgs/api-platform/people), so he/she will get a notification. * Squash your commits into one commit (see the next chapter). -All Pull Requests must include [this header](.github/PULL_REQUEST_TEMPLATE.md). - ### Tests On `api-platform/core` there are two kinds of tests: unit (`phpunit`) and integration tests (`behat`). From 38d69556f7b33a2f6cf56e8eb4ffc76290144745 Mon Sep 17 00:00:00 2001 From: penja Date: Sat, 28 Dec 2019 17:36:58 +0200 Subject: [PATCH 050/160] add property schema restrictions based on validation constraints --- .../ApiPlatformExtension.php | 3 + .../Bundle/Resources/config/validator.xml | 13 ++ .../Restriction/PropertySchemaFormat.php | 62 +++++++ .../PropertySchemaLengthRestriction.php | 70 ++++++++ .../PropertySchemaRegexRestriction.php | 42 +++++ ...ertySchemaRestrictionMetadataInterface.php | 43 +++++ .../ValidatorPropertyMetadataFactory.php | 81 ++++++--- src/JsonSchema/SchemaFactory.php | 8 +- src/Metadata/Property/PropertyMetadata.php | 23 ++- .../ApiPlatformExtensionTest.php | 8 + .../ValidatorPropertyMetadataFactoryTest.php | 155 +++++++++++++++++- tests/Fixtures/DummyValidatedEntity.php | 30 ++++ .../DocumentationNormalizerV3Test.php | 7 +- 13 files changed, 511 insertions(+), 34 deletions(-) create mode 100644 src/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaFormat.php create mode 100644 src/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLengthRestriction.php create mode 100644 src/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaRegexRestriction.php create mode 100644 src/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaRestrictionMetadataInterface.php diff --git a/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index de9a2031c03..af6d2514150 100644 --- a/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -23,6 +23,7 @@ use ApiPlatform\Core\Bridge\Doctrine\Orm\Extension\QueryItemExtensionInterface; use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\AbstractContextAwareFilter as DoctrineOrmAbstractContextAwareFilter; use ApiPlatform\Core\Bridge\Elasticsearch\DataProvider\Extension\RequestBodySearchCollectionExtensionInterface; +use ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaRestrictionMetadataInterface; use ApiPlatform\Core\DataPersister\DataPersisterInterface; use ApiPlatform\Core\DataProvider\CollectionDataProviderInterface; use ApiPlatform\Core\DataProvider\ItemDataProviderInterface; @@ -553,6 +554,8 @@ private function registerValidatorConfiguration(ContainerBuilder $container, arr { if (interface_exists(ValidatorInterface::class)) { $loader->load('validator.xml'); + $container->registerForAutoconfiguration(PropertySchemaRestrictionMetadataInterface::class) + ->addTag('api_platform.metadata.property_schema_restriction'); } if (!$config['validator']) { diff --git a/src/Bridge/Symfony/Bundle/Resources/config/validator.xml b/src/Bridge/Symfony/Bundle/Resources/config/validator.xml index 2526c93cfc6..9a5a50a6d7e 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/validator.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/validator.xml @@ -14,6 +14,19 @@ + + + + + + + + + + + + + diff --git a/src/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaFormat.php b/src/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaFormat.php new file mode 100644 index 00000000000..67990bfcab5 --- /dev/null +++ b/src/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaFormat.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\Bridge\Symfony\Validator\Metadata\Property\Restriction; + +use ApiPlatform\Core\Metadata\Property\PropertyMetadata; +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\Constraints\Email; +use Symfony\Component\Validator\Constraints\Ip; +use Symfony\Component\Validator\Constraints\Uuid; + +/** + * Class PropertySchemaFormat. + * + * @author Andrii Penchuk penja7@gmail.com + */ +class PropertySchemaFormat implements PropertySchemaRestrictionMetadataInterface +{ + /** + * {@inheritdoc} + */ + public function create(Constraint $constraint, PropertyMetadata $propertyMetadata): array + { + if ($constraint instanceof Email) { + return ['format' => 'email']; + } + + if ($constraint instanceof Uuid) { + return ['format' => 'uuid']; + } + + if ($constraint instanceof Ip) { + if ($constraint->version === $constraint::V4) { + return ['format' => 'ipv4']; + } + + return ['format' => 'ipv6']; + } + + return []; + } + + /** + * {@inheritdoc} + */ + public function supports(Constraint $constraint, PropertyMetadata $propertyMetadata): bool + { + $schema = $propertyMetadata->getSchema(); + + return empty($schema['format']); + } +} diff --git a/src/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLengthRestriction.php b/src/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLengthRestriction.php new file mode 100644 index 00000000000..c6c2a7abffa --- /dev/null +++ b/src/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLengthRestriction.php @@ -0,0 +1,70 @@ + + * + * 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\Bridge\Symfony\Validator\Metadata\Property\Restriction; + +use ApiPlatform\Core\Metadata\Property\PropertyMetadata; +use Symfony\Component\PropertyInfo\Type; +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\Constraints\Length; + +/** + * Class PropertySchemaLengthRestrictions. + * + * @author Andrii Penchuk penja7@gmail.com + */ +class PropertySchemaLengthRestriction implements PropertySchemaRestrictionMetadataInterface +{ + /** + * {@inheritdoc} + */ + public function create(Constraint $constraint, PropertyMetadata $propertyMetadata): array + { + $restriction = []; + + switch ($propertyMetadata->getType()->getBuiltinType()) { + case Type::BUILTIN_TYPE_STRING: + + if (isset($constraint->min)) { + $restriction['minLength'] = (int) $constraint->min; + } + + if (isset($constraint->max)) { + $restriction['maxLength'] = (int) $constraint->max; + } + + break; + case Type::BUILTIN_TYPE_INT: + case Type::BUILTIN_TYPE_FLOAT: + if (isset($constraint->min)) { + $restriction['minimum'] = (int) $constraint->min; + } + + if (isset($constraint->max)) { + $restriction['maximum'] = (int) $constraint->max; + } + + break; + } + + return $restriction; + } + + /** + * {@inheritdoc} + */ + public function supports(Constraint $constraint, PropertyMetadata $propertyMetadata): bool + { + return $constraint instanceof Length && null !== $propertyMetadata->getType(); + } +} diff --git a/src/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaRegexRestriction.php b/src/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaRegexRestriction.php new file mode 100644 index 00000000000..dccc81e9fbd --- /dev/null +++ b/src/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaRegexRestriction.php @@ -0,0 +1,42 @@ + + * + * 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\Bridge\Symfony\Validator\Metadata\Property\Restriction; + +use ApiPlatform\Core\Metadata\Property\PropertyMetadata; +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\Constraints\Regex; + +/** + * Class PropertySchemaRegexRestriction. + * + * @author Andrii Penchuk penja7@gmail.com + */ +class PropertySchemaRegexRestriction implements PropertySchemaRestrictionMetadataInterface +{ + /** + * {@inheritdoc} + */ + public function create(Constraint $constraint, PropertyMetadata $propertyMetadata): array + { + return isset($constraint->pattern) ? ['pattern' => $constraint->pattern] : []; + } + + /** + * {@inheritdoc} + */ + public function supports(Constraint $constraint, PropertyMetadata $propertyMetadata): bool + { + return $constraint instanceof Regex && $constraint->match; + } +} diff --git a/src/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaRestrictionMetadataInterface.php b/src/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaRestrictionMetadataInterface.php new file mode 100644 index 00000000000..2486342de40 --- /dev/null +++ b/src/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaRestrictionMetadataInterface.php @@ -0,0 +1,43 @@ + + * + * 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\Bridge\Symfony\Validator\Metadata\Property\Restriction; + +use ApiPlatform\Core\Metadata\Property\PropertyMetadata; +use Symfony\Component\Validator\Constraint; + +/** + * Interface PropertySchemaRestrictionsInterface. + * + * @author Andrii Penchuk penja7@gmail.com + */ +interface PropertySchemaRestrictionMetadataInterface +{ + /** + * Creates json schema restrictions based on the validation constraints. + * + * @param Constraint $constraint The validation constraint + * @param PropertyMetadata $propertyMetadata The property metadata + * + * @return array The array of restrictions + */ + public function create(Constraint $constraint, PropertyMetadata $propertyMetadata): array; + + /** + * Is the constraint supported by the schema restriction? + * + * @param Constraint $constraint The validation constraint + * @param PropertyMetadata $propertyMetadata The property metadata + */ + public function supports(Constraint $constraint, PropertyMetadata $propertyMetadata): bool; +} diff --git a/src/Bridge/Symfony/Validator/Metadata/Property/ValidatorPropertyMetadataFactory.php b/src/Bridge/Symfony/Validator/Metadata/Property/ValidatorPropertyMetadataFactory.php index 5568720c592..438f20d1a2d 100644 --- a/src/Bridge/Symfony/Validator/Metadata/Property/ValidatorPropertyMetadataFactory.php +++ b/src/Bridge/Symfony/Validator/Metadata/Property/ValidatorPropertyMetadataFactory.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property; +use ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaRestrictionMetadataInterface; use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Property\PropertyMetadata; use Symfony\Component\Validator\Constraint; @@ -67,11 +68,19 @@ final class ValidatorPropertyMetadataFactory implements PropertyMetadataFactoryI private $decorated; private $validatorMetadataFactory; + /** + * @var iterable + */ + private $restrictionsMetadata; - public function __construct(ValidatorMetadataFactoryInterface $validatorMetadataFactory, PropertyMetadataFactoryInterface $decorated) + /** + * @param PropertySchemaRestrictionMetadataInterface[] $restrictionsMetadata + */ + public function __construct(ValidatorMetadataFactoryInterface $validatorMetadataFactory, PropertyMetadataFactoryInterface $decorated, iterable $restrictionsMetadata = []) { $this->validatorMetadataFactory = $validatorMetadataFactory; $this->decorated = $decorated; + $this->restrictionsMetadata = $restrictionsMetadata; } /** @@ -83,26 +92,23 @@ public function create(string $resourceClass, string $name, array $options = []) $required = $propertyMetadata->isRequired(); $iri = $propertyMetadata->getIri(); + $schema = $propertyMetadata->getSchema(); - if (null !== $required && null !== $iri) { + if (null !== $required && null !== $iri && null !== $schema) { return $propertyMetadata; } $validatorClassMetadata = $this->validatorMetadataFactory->getMetadataFor($resourceClass); + if (!$validatorClassMetadata instanceof ValidatorClassMetadataInterface) { throw new \UnexpectedValueException(sprintf('Validator class metadata expected to be of type "%s".', ValidatorClassMetadataInterface::class)); } - foreach ($validatorClassMetadata->getPropertyMetadata($name) as $validatorPropertyMetadata) { - if (null === $required && isset($options['validation_groups'])) { - $required = $this->isRequiredByGroups($validatorPropertyMetadata, $options); - } - - if (!method_exists($validatorClassMetadata, 'getDefaultGroup')) { - throw new \UnexpectedValueException(sprintf('Validator class metadata expected to have method "%s".', 'getDefaultGroup')); - } + $validationGroups = $this->getValidationGroups($validatorClassMetadata, $options); + $restrictions = []; - foreach ($validatorPropertyMetadata->findConstraints($validatorClassMetadata->getDefaultGroup()) as $constraint) { + foreach ($validatorClassMetadata->getPropertyMetadata($name) as $validatorPropertyMetadata) { + foreach ($this->getPropertyConstraints($validatorPropertyMetadata, $validationGroups) as $constraint) { if (null === $required && $this->isRequired($constraint)) { $required = true; } @@ -111,33 +117,64 @@ public function create(string $resourceClass, string $name, array $options = []) $iri = self::SCHEMA_MAPPED_CONSTRAINTS[\get_class($constraint)] ?? null; } - if (null !== $required && null !== $iri) { - break 2; + foreach ($this->restrictionsMetadata as $restrictionMetadata) { + if ($restrictionMetadata->supports($constraint, $propertyMetadata)) { + $restrictions[] = $restrictionMetadata->create($constraint, $propertyMetadata); + } } } } - return $propertyMetadata->withIri($iri)->withRequired($required ?? false); + $propertyMetadata = $propertyMetadata->withIri($iri)->withRequired($required ?? false); + + if (!empty($restrictions)) { + if (null === $schema) { + $schema = []; + } + + $schema += array_merge(...$restrictions); + $propertyMetadata = $propertyMetadata->withSchema($schema); + } + + return $propertyMetadata; } /** - * Tests if the property is required because of its validation groups. + * Returns the list of validation groups. */ - private function isRequiredByGroups(ValidatorPropertyMetadataInterface $validatorPropertyMetadata, array $options): bool + private function getValidationGroups(ValidatorClassMetadataInterface $classMetadata, array $options): array { - foreach ($options['validation_groups'] as $validationGroup) { + if (isset($options['validation_groups'])) { + return $options['validation_groups']; + } + + if (!method_exists($classMetadata, 'getDefaultGroup')) { + throw new \UnexpectedValueException(sprintf('Validator class metadata expected to have method "%s".', 'getDefaultGroup')); + } + + return [$classMetadata->getDefaultGroup()]; + } + + /** + * Tests if the property is required because of its validation groups. + */ + private function getPropertyConstraints( + ValidatorPropertyMetadataInterface $validatorPropertyMetadata, + array $groups + ): array { + $constraints = []; + + foreach ($groups as $validationGroup) { if (!\is_string($validationGroup)) { continue; } - foreach ($validatorPropertyMetadata->findConstraints($validationGroup) as $constraint) { - if ($this->isRequired($constraint)) { - return true; - } + foreach ($validatorPropertyMetadata->findConstraints($validationGroup) as $propertyConstraint) { + $constraints[] = $propertyConstraint; } } - return false; + return $constraints; } /** diff --git a/src/JsonSchema/SchemaFactory.php b/src/JsonSchema/SchemaFactory.php index fe673a83bfc..8790b6af5da 100644 --- a/src/JsonSchema/SchemaFactory.php +++ b/src/JsonSchema/SchemaFactory.php @@ -153,6 +153,8 @@ private function buildPropertySchema(Schema $schema, string $definitionName, str { $version = $schema->getVersion(); $swagger = false; + $propertySchema = $propertyMetadata->getSchema() ?? []; + switch ($version) { case Schema::VERSION_SWAGGER: $swagger = true; @@ -165,7 +167,11 @@ private function buildPropertySchema(Schema $schema, string $definitionName, str $basePropertySchemaAttribute = 'json_schema_context'; } - $propertySchema = $propertyMetadata->getAttributes()[$basePropertySchemaAttribute] ?? []; + $propertySchema = array_merge( + $propertySchema, + $propertyMetadata->getAttributes()[$basePropertySchemaAttribute] ?? [] + ); + if (false === $propertyMetadata->isWritable() && !$propertyMetadata->isInitializable()) { $propertySchema['readOnly'] = true; } diff --git a/src/Metadata/Property/PropertyMetadata.php b/src/Metadata/Property/PropertyMetadata.php index 1018d9726ec..4629b89e291 100644 --- a/src/Metadata/Property/PropertyMetadata.php +++ b/src/Metadata/Property/PropertyMetadata.php @@ -46,8 +46,9 @@ final class PropertyMetadata * @var null */ private $example; + private $schema; - public function __construct(Type $type = null, string $description = null, bool $readable = null, bool $writable = null, bool $readableLink = null, bool $writableLink = null, bool $required = null, bool $identifier = null, string $iri = null, $childInherited = null, array $attributes = null, SubresourceMetadata $subresource = null, bool $initializable = null, $default = null, $example = null) + public function __construct(Type $type = null, string $description = null, bool $readable = null, bool $writable = null, bool $readableLink = null, bool $writableLink = null, bool $required = null, bool $identifier = null, string $iri = null, $childInherited = null, array $attributes = null, SubresourceMetadata $subresource = null, bool $initializable = null, $default = null, $example = null, array $schema = null) { $this->type = $type; $this->description = $description; @@ -67,6 +68,7 @@ public function __construct(Type $type = null, string $description = null, bool $this->initializable = $initializable; $this->default = $default; $this->example = $example; + $this->schema = $schema; } /** @@ -397,4 +399,23 @@ public function withExample($example): self return $metadata; } + + /** + * @return array + */ + public function getSchema(): ?array + { + return $this->schema; + } + + /** + * Returns a new instance with the given schema. + */ + public function withSchema(array $schema = null): self + { + $metadata = clone $this; + $metadata->schema = $schema; + + return $metadata; + } } diff --git a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php index 604c1d508eb..a9dc1fdb20c 100644 --- a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php +++ b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php @@ -55,6 +55,7 @@ use ApiPlatform\Core\Bridge\Elasticsearch\DataProvider\Filter\TermFilter; use ApiPlatform\Core\Bridge\Elasticsearch\Metadata\Document\Factory\DocumentMetadataFactoryInterface; use ApiPlatform\Core\Bridge\Symfony\Bundle\DependencyInjection\ApiPlatformExtension; +use ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaRestrictionMetadataInterface; use ApiPlatform\Core\DataPersister\DataPersisterInterface; use ApiPlatform\Core\DataProvider\CollectionDataProviderInterface; use ApiPlatform\Core\DataProvider\ItemDataProviderInterface; @@ -1089,6 +1090,10 @@ private function getBaseContainerBuilderProphecy(array $doctrineIntegrationsToLo ->willReturn($this->childDefinitionProphecy)->shouldBeCalledTimes(1); $this->childDefinitionProphecy->setBindings(['$requestStack' => null])->shouldBeCalledTimes(1); + $containerBuilderProphecy->registerForAutoconfiguration(PropertySchemaRestrictionMetadataInterface::class) + ->willReturn($this->childDefinitionProphecy)->shouldBeCalledTimes(1); + $this->childDefinitionProphecy->addTag('api_platform.metadata.property_schema_restriction')->shouldBeCalledTimes(1); + if (\in_array('odm', $doctrineIntegrationsToLoad, true)) { $containerBuilderProphecy->registerForAutoconfiguration(AggregationItemExtensionInterface::class) ->willReturn($this->childDefinitionProphecy)->shouldBeCalledTimes(1); @@ -1231,6 +1236,9 @@ private function getBaseContainerBuilderProphecy(array $doctrineIntegrationsToLo 'api_platform.metadata.extractor.yaml', 'api_platform.metadata.property.metadata_factory.annotation', 'api_platform.metadata.property.metadata_factory.validator', + 'api_platform.metadata.property_schema.length_restriction', + 'api_platform.metadata.property_schema.regex_restriction', + 'api_platform.metadata.property_schema.format_restriction', 'api_platform.metadata.property.metadata_factory.yaml', 'api_platform.metadata.property.name_collection_factory.yaml', 'api_platform.metadata.resource.filter_metadata_factory.annotation', diff --git a/tests/Bridge/Symfony/Validator/Metadata/Property/ValidatorPropertyMetadataFactoryTest.php b/tests/Bridge/Symfony/Validator/Metadata/Property/ValidatorPropertyMetadataFactoryTest.php index 3ae3a29a27e..1e989499ecd 100644 --- a/tests/Bridge/Symfony/Validator/Metadata/Property/ValidatorPropertyMetadataFactoryTest.php +++ b/tests/Bridge/Symfony/Validator/Metadata/Property/ValidatorPropertyMetadataFactoryTest.php @@ -13,6 +13,9 @@ namespace ApiPlatform\Core\Tests\Bridge\Symfony\Validator\Metadata\Property; +use ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaFormat; +use ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaLengthRestriction; +use ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaRegexRestriction; use ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\ValidatorPropertyMetadataFactory; use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Property\PropertyMetadata; @@ -20,6 +23,7 @@ use ApiPlatform\Core\Tests\Fixtures\DummyValidatedEntity; use Doctrine\Common\Annotations\AnnotationReader; use PHPUnit\Framework\TestCase; +use Symfony\Component\PropertyInfo\Type; use Symfony\Component\Validator\Mapping\ClassMetadata; use Symfony\Component\Validator\Mapping\Factory\MetadataFactoryInterface; use Symfony\Component\Validator\Mapping\Loader\AnnotationLoader; @@ -48,7 +52,11 @@ public function testCreateWithPropertyWithRequiredConstraints() $validatorMetadataFactory = $this->prophesize(MetadataFactoryInterface::class); $validatorMetadataFactory->getMetadataFor(DummyValidatedEntity::class)->willReturn($this->validatorClassMetadata)->shouldBeCalled(); - $validatorPropertyMetadataFactory = new ValidatorPropertyMetadataFactory($validatorMetadataFactory->reveal(), $decoratedPropertyMetadataFactory->reveal()); + $validatorPropertyMetadataFactory = new ValidatorPropertyMetadataFactory( + $validatorMetadataFactory->reveal(), + $decoratedPropertyMetadataFactory->reveal(), + [] + ); $resultedPropertyMetadata = $validatorPropertyMetadataFactory->create(DummyValidatedEntity::class, 'dummy'); $this->assertEquals($expectedPropertyMetadata, $resultedPropertyMetadata); @@ -66,7 +74,11 @@ public function testCreateWithPropertyWithNotRequiredConstraints() $validatorMetadataFactory = $this->prophesize(MetadataFactoryInterface::class); $validatorMetadataFactory->getMetadataFor(DummyValidatedEntity::class)->willReturn($this->validatorClassMetadata)->shouldBeCalled(); - $validatorPropertyMetadataFactory = new ValidatorPropertyMetadataFactory($validatorMetadataFactory->reveal(), $decoratedPropertyMetadataFactory->reveal()); + $validatorPropertyMetadataFactory = new ValidatorPropertyMetadataFactory( + $validatorMetadataFactory->reveal(), + $decoratedPropertyMetadataFactory->reveal(), + [] + ); $resultedPropertyMetadata = $validatorPropertyMetadataFactory->create(DummyValidatedEntity::class, 'dummyDate'); $this->assertEquals($expectedPropertyMetadata, $resultedPropertyMetadata); @@ -83,7 +95,11 @@ public function testCreateWithPropertyWithoutConstraints() $validatorMetadataFactory = $this->prophesize(MetadataFactoryInterface::class); $validatorMetadataFactory->getMetadataFor(DummyValidatedEntity::class)->willReturn($this->validatorClassMetadata)->shouldBeCalled(); - $validatorPropertyMetadataFactory = new ValidatorPropertyMetadataFactory($validatorMetadataFactory->reveal(), $decoratedPropertyMetadataFactory->reveal()); + $validatorPropertyMetadataFactory = new ValidatorPropertyMetadataFactory( + $validatorMetadataFactory->reveal(), + $decoratedPropertyMetadataFactory->reveal(), + [] + ); $resultedPropertyMetadata = $validatorPropertyMetadataFactory->create(DummyValidatedEntity::class, 'dummyId'); $this->assertEquals($expectedPropertyMetadata, $resultedPropertyMetadata); @@ -100,7 +116,11 @@ public function testCreateWithPropertyWithRightValidationGroupsAndRequiredConstr $validatorMetadataFactory = $this->prophesize(MetadataFactoryInterface::class); $validatorMetadataFactory->getMetadataFor(DummyValidatedEntity::class)->willReturn($this->validatorClassMetadata)->shouldBeCalled(); - $validatorPropertyMetadataFactory = new ValidatorPropertyMetadataFactory($validatorMetadataFactory->reveal(), $decoratedPropertyMetadataFactory->reveal()); + $validatorPropertyMetadataFactory = new ValidatorPropertyMetadataFactory( + $validatorMetadataFactory->reveal(), + $decoratedPropertyMetadataFactory->reveal(), + [] + ); $resultedPropertyMetadata = $validatorPropertyMetadataFactory->create(DummyValidatedEntity::class, 'dummyGroup', ['validation_groups' => ['dummy']]); $this->assertEquals($expectedPropertyMetadata, $resultedPropertyMetadata); @@ -117,7 +137,11 @@ public function testCreateWithPropertyWithBadValidationGroupsAndRequiredConstrai $validatorMetadataFactory = $this->prophesize(MetadataFactoryInterface::class); $validatorMetadataFactory->getMetadataFor(DummyValidatedEntity::class)->willReturn($this->validatorClassMetadata)->shouldBeCalled(); - $validatorPropertyMetadataFactory = new ValidatorPropertyMetadataFactory($validatorMetadataFactory->reveal(), $decoratedPropertyMetadataFactory->reveal()); + $validatorPropertyMetadataFactory = new ValidatorPropertyMetadataFactory( + $validatorMetadataFactory->reveal(), + $decoratedPropertyMetadataFactory->reveal(), + [] + ); $resultedPropertyMetadata = $validatorPropertyMetadataFactory->create(DummyValidatedEntity::class, 'dummyGroup', ['validation_groups' => ['ymmud']]); $this->assertEquals($expectedPropertyMetadata, $resultedPropertyMetadata); @@ -134,7 +158,11 @@ public function testCreateWithPropertyWithNonStringValidationGroupsAndRequiredCo $validatorMetadataFactory = $this->prophesize(MetadataFactoryInterface::class); $validatorMetadataFactory->getMetadataFor(DummyValidatedEntity::class)->willReturn($this->validatorClassMetadata)->shouldBeCalled(); - $validatorPropertyMetadataFactory = new ValidatorPropertyMetadataFactory($validatorMetadataFactory->reveal(), $decoratedPropertyMetadataFactory->reveal()); + $validatorPropertyMetadataFactory = new ValidatorPropertyMetadataFactory( + $validatorMetadataFactory->reveal(), + $decoratedPropertyMetadataFactory->reveal(), + [] + ); $resultedPropertyMetadata = $validatorPropertyMetadataFactory->create(DummyValidatedEntity::class, 'dummyGroup', ['validation_groups' => [1312]]); $this->assertEquals($expectedPropertyMetadata, $resultedPropertyMetadata); @@ -149,8 +177,13 @@ public function testCreateWithRequiredByDecorated() $decoratedPropertyMetadataFactory->create(DummyValidatedEntity::class, 'dummyDate', [])->willReturn($propertyMetadata)->shouldBeCalled(); $validatorMetadataFactory = $this->prophesize(MetadataFactoryInterface::class); + $validatorMetadataFactory->getMetadataFor(DummyValidatedEntity::class)->willReturn($this->validatorClassMetadata)->shouldBeCalled(); - $validatorPropertyMetadataFactory = new ValidatorPropertyMetadataFactory($validatorMetadataFactory->reveal(), $decoratedPropertyMetadataFactory->reveal()); + $validatorPropertyMetadataFactory = new ValidatorPropertyMetadataFactory( + $validatorMetadataFactory->reveal(), + $decoratedPropertyMetadataFactory->reveal(), + [] + ); $resultedPropertyMetadata = $validatorPropertyMetadataFactory->create(DummyValidatedEntity::class, 'dummyDate'); $this->assertEquals($expectedPropertyMetadata, $resultedPropertyMetadata); @@ -186,11 +219,117 @@ public function testCreateWithPropertyWithValidationConstraints() $validatorMetadataFactory = $this->prophesize(MetadataFactoryInterface::class); $validatorMetadataFactory->getMetadataFor(DummyIriWithValidationEntity::class)->willReturn($validatorClassMetadata)->shouldBeCalled(); - $validatorPropertyMetadataFactory = new ValidatorPropertyMetadataFactory($validatorMetadataFactory->reveal(), $decoratedPropertyMetadataFactory->reveal()); + $validatorPropertyMetadataFactory = new ValidatorPropertyMetadataFactory( + $validatorMetadataFactory->reveal(), + $decoratedPropertyMetadataFactory->reveal(), + [] + ); foreach ($types as $property => $iri) { $resultedPropertyMetadata = $validatorPropertyMetadataFactory->create(DummyIriWithValidationEntity::class, $property); $this->assertSame($iri, $resultedPropertyMetadata->getIri()); } } + + public function testCreateWithPropertyLengthRestriction(): void + { + $validatorClassMetadata = new ClassMetadata(DummyValidatedEntity::class); + (new AnnotationLoader(new AnnotationReader()))->loadClassMetadata($validatorClassMetadata); + + $validatorMetadataFactory = $this->prophesize(MetadataFactoryInterface::class); + $validatorMetadataFactory->getMetadataFor(DummyValidatedEntity::class) + ->willReturn($validatorClassMetadata) + ->shouldBeCalled(); + + $decoratedPropertyMetadataFactory = $this->prophesize(PropertyMetadataFactoryInterface::class); + $property = 'dummy'; + $decoratedPropertyMetadataFactory->create(DummyValidatedEntity::class, $property, [])->willReturn( + new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING)) + )->shouldBeCalled(); + + $lengthRestrictions = new PropertySchemaLengthRestriction(); + $validatorPropertyMetadataFactory = new ValidatorPropertyMetadataFactory( + $validatorMetadataFactory->reveal(), $decoratedPropertyMetadataFactory->reveal(), [$lengthRestrictions] + ); + + $schema = $validatorPropertyMetadataFactory->create(DummyValidatedEntity::class, $property)->getSchema(); + $this->assertNotNull($schema); + $this->assertArrayHasKey('minLength', $schema); + $this->assertArrayHasKey('maxLength', $schema); + + $numberTypes = [Type::BUILTIN_TYPE_INT, Type::BUILTIN_TYPE_FLOAT]; + + foreach ($numberTypes as $type) { + $decoratedPropertyMetadataFactory->create(DummyValidatedEntity::class, $property, [])->willReturn( + new PropertyMetadata(new Type($type)) + )->shouldBeCalled(); + $validatorPropertyMetadataFactory = new ValidatorPropertyMetadataFactory( + $validatorMetadataFactory->reveal(), $decoratedPropertyMetadataFactory->reveal(), [$lengthRestrictions] + ); + + $schema = $validatorPropertyMetadataFactory->create(DummyValidatedEntity::class, $property)->getSchema(); + $this->assertNotNull($schema); + $this->assertArrayHasKey('minimum', $schema); + $this->assertArrayHasKey('maximum', $schema); + } + } + + public function testCreateWithPropertyRegexRestriction(): void + { + $validatorClassMetadata = new ClassMetadata(DummyValidatedEntity::class); + (new AnnotationLoader(new AnnotationReader()))->loadClassMetadata($validatorClassMetadata); + + $validatorMetadataFactory = $this->prophesize(MetadataFactoryInterface::class); + $validatorMetadataFactory->getMetadataFor(DummyValidatedEntity::class) + ->willReturn($validatorClassMetadata) + ->shouldBeCalled(); + + $decoratedPropertyMetadataFactory = $this->prophesize(PropertyMetadataFactoryInterface::class); + $decoratedPropertyMetadataFactory->create(DummyValidatedEntity::class, 'dummy', [])->willReturn( + new PropertyMetadata() + )->shouldBeCalled(); + + $validationPropertyMetadataFactory = new ValidatorPropertyMetadataFactory( + $validatorMetadataFactory->reveal(), $decoratedPropertyMetadataFactory->reveal(), + [new PropertySchemaRegexRestriction()] + ); + + $schema = $validationPropertyMetadataFactory->create(DummyValidatedEntity::class, 'dummy')->getSchema(); + $this->assertNotNull($schema); + $this->assertArrayHasKey('pattern', $schema); + $this->assertEquals('^dummy$', $schema['pattern']); + } + + public function testCreateWithPropertyFormatRestriction(): void + { + $validatorClassMetadata = new ClassMetadata(DummyValidatedEntity::class); + (new AnnotationLoader(new AnnotationReader()))->loadClassMetadata($validatorClassMetadata); + + $validatorMetadataFactory = $this->prophesize(MetadataFactoryInterface::class); + $validatorMetadataFactory->getMetadataFor(DummyValidatedEntity::class) + ->willReturn($validatorClassMetadata) + ->shouldBeCalled(); + $formats = [ + 'dummyEmail' => 'email', + 'dummyUuid' => 'uuid', + 'dummyIpv4' => 'ipv4', + 'dummyIpv6' => 'ipv6', + ]; + + foreach ($formats as $property => $format) { + $decoratedPropertyMetadataFactory = $this->prophesize(PropertyMetadataFactoryInterface::class); + $decoratedPropertyMetadataFactory->create(DummyValidatedEntity::class, $property, [])->willReturn( + new PropertyMetadata() + )->shouldBeCalled(); + $validationPropertyMetadataFactory = new ValidatorPropertyMetadataFactory( + $validatorMetadataFactory->reveal(), + $decoratedPropertyMetadataFactory->reveal(), + [new PropertySchemaFormat()] + ); + $schema = $validationPropertyMetadataFactory->create(DummyValidatedEntity::class, $property)->getSchema(); + $this->assertNotNull($schema); + $this->assertArrayHasKey('format', $schema); + $this->assertEquals($format, $schema['format']); + } + } } diff --git a/tests/Fixtures/DummyValidatedEntity.php b/tests/Fixtures/DummyValidatedEntity.php index 8314774b654..81d3e4b852d 100644 --- a/tests/Fixtures/DummyValidatedEntity.php +++ b/tests/Fixtures/DummyValidatedEntity.php @@ -31,9 +31,39 @@ class DummyValidatedEntity * @var string A dummy * * @Assert\NotBlank + * @Assert\Length(max="4", min="10") + * @Assert\Regex(pattern="^dummy$") */ public $dummy; + /** + * @var string + * + * @Assert\Email + */ + public $dummyEmail; + + /** + * @var string + * + * @Assert\Uuid + */ + public $dummyUuid; + + /** + * @var string + * + * @Assert\Ip + */ + public $dummyIpv4; + + /** + * @var string + * + * @Assert\Ip(version="6") + */ + public $dummyIpv6; + /** * @var \DateTimeInterface A dummy date * diff --git a/tests/Swagger/Serializer/DocumentationNormalizerV3Test.php b/tests/Swagger/Serializer/DocumentationNormalizerV3Test.php index d6250fb3b90..92499eafe50 100644 --- a/tests/Swagger/Serializer/DocumentationNormalizerV3Test.php +++ b/tests/Swagger/Serializer/DocumentationNormalizerV3Test.php @@ -111,8 +111,8 @@ private function doTestNormalize(OperationMethodResolverInterface $operationMeth $resourceMetadataFactoryProphecy->create(Dummy::class)->shouldBeCalled()->willReturn($dummyMetadata); $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'id')->shouldBeCalled()->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_INT), 'This is an id.', true, false, null, null, null, true)); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'name')->shouldBeCalled()->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING), 'This is a name.', true, true, true, true, false, false, null, null, [])); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'id')->shouldBeCalled()->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_INT), 'This is an id.', true, false, null, null, null, true, null, null, null, null, null, null, null, ['minLength' => 3, 'maxLength' => 20, 'pattern' => '^dummyPattern$'])); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'name')->shouldBeCalled()->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING), 'This is a name.', true, true, true, true, false, false, null, null, [], null, null, null, null)); $propertyMetadataFactoryProphecy->create(Dummy::class, 'description')->shouldBeCalled()->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING), 'This is an initializable but not writable property.', true, false, true, true, false, false, null, null, [], null, true)); $propertyMetadataFactoryProphecy->create(Dummy::class, 'dummyDate')->shouldBeCalled()->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_OBJECT, true, \DateTime::class), 'This is a \DateTimeInterface object.', true, true, true, true, false, false, null, null, [])); @@ -375,6 +375,9 @@ private function doTestNormalize(OperationMethodResolverInterface $operationMeth 'type' => 'integer', 'description' => 'This is an id.', 'readOnly' => true, + 'minLength' => 3, + 'maxLength' => 20, + 'pattern' => '^dummyPattern$', ]), 'name' => new \ArrayObject([ 'type' => 'string', From 39fc0a443a90d096b822d30cac2207cfffc0f525 Mon Sep 17 00:00:00 2001 From: Tomas Date: Thu, 16 Jan 2020 08:47:00 +0200 Subject: [PATCH 051/160] Allow to autoconfigure validation groups generators Co-authored-by: Teoh Han Hui --- CHANGELOG.md | 1 + .../ApiPlatformExtension.php | 7 +++- .../ValidationGroupsGeneratorInterface.php | 31 ++++++++++++++ src/Bridge/Symfony/Validator/Validator.php | 4 ++ .../ApiPlatformExtensionTest.php | 6 +++ .../Symfony/Validator/ValidatorTest.php | 40 +++++++++++++++---- .../DummyValidationGroupsGenerator.php | 8 +++- 7 files changed, 87 insertions(+), 10 deletions(-) create mode 100644 src/Bridge/Symfony/Validator/ValidationGroupsGeneratorInterface.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 50673344ca3..1896e35441b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ * GraphQL: Allow to format GraphQL errors based on exceptions (#3063) * GraphQL: Add page-based pagination (#3175) * OpenAPI: Add PHP default values to the documentation (#2386) +* Deprecate using a validation groups generator service not implementing `ApiPlatform\Core\Bridge\Symfony\Validator\ValidationGroupsGeneratorInterface` (#3346) ## 2.5.x-dev diff --git a/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index af6d2514150..a0e76982fe0 100644 --- a/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -24,6 +24,7 @@ use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\AbstractContextAwareFilter as DoctrineOrmAbstractContextAwareFilter; use ApiPlatform\Core\Bridge\Elasticsearch\DataProvider\Extension\RequestBodySearchCollectionExtensionInterface; use ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaRestrictionMetadataInterface; +use ApiPlatform\Core\Bridge\Symfony\Validator\ValidationGroupsGeneratorInterface; use ApiPlatform\Core\DataPersister\DataPersisterInterface; use ApiPlatform\Core\DataProvider\CollectionDataProviderInterface; use ApiPlatform\Core\DataProvider\ItemDataProviderInterface; @@ -554,8 +555,12 @@ private function registerValidatorConfiguration(ContainerBuilder $container, arr { if (interface_exists(ValidatorInterface::class)) { $loader->load('validator.xml'); + + $container->registerForAutoconfiguration(ValidationGroupsGeneratorInterface::class) + ->addTag('api_platform.validation_groups_generator') + ->setPublic(true); // this line should be removed in 3.0 $container->registerForAutoconfiguration(PropertySchemaRestrictionMetadataInterface::class) - ->addTag('api_platform.metadata.property_schema_restriction'); + ->addTag('api_platform.metadata.property_schema_restriction'); } if (!$config['validator']) { diff --git a/src/Bridge/Symfony/Validator/ValidationGroupsGeneratorInterface.php b/src/Bridge/Symfony/Validator/ValidationGroupsGeneratorInterface.php new file mode 100644 index 00000000000..d8fffcef5ae --- /dev/null +++ b/src/Bridge/Symfony/Validator/ValidationGroupsGeneratorInterface.php @@ -0,0 +1,31 @@ + + * + * 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\Bridge\Symfony\Validator; + +use Symfony\Component\Validator\Constraints\GroupSequence; + +/** + * Generates validation groups for an object. + * + * @author Tomas Norkūnas + */ +interface ValidationGroupsGeneratorInterface +{ + /** + * @param object $object + * + * @return GroupSequence|string[] + */ + public function __invoke($object); +} diff --git a/src/Bridge/Symfony/Validator/Validator.php b/src/Bridge/Symfony/Validator/Validator.php index b804999e732..a7aca34c75c 100644 --- a/src/Bridge/Symfony/Validator/Validator.php +++ b/src/Bridge/Symfony/Validator/Validator.php @@ -50,6 +50,10 @@ public function validate($data, array $context = []) ($service = $this->container->get($validationGroups)) && \is_callable($service) ) { + if (!$service instanceof ValidationGroupsGeneratorInterface) { + @trigger_error(sprintf('Using a public validation groups generator service not implementing "%s" is deprecated since 2.6 and will be removed in 3.0.', ValidationGroupsGeneratorInterface::class), E_USER_DEPRECATED); + } + $validationGroups = $service($data); } elseif (\is_callable($validationGroups)) { $validationGroups = $validationGroups($data); diff --git a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php index a9dc1fdb20c..d9cc64cfdb1 100644 --- a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php +++ b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php @@ -56,6 +56,7 @@ use ApiPlatform\Core\Bridge\Elasticsearch\Metadata\Document\Factory\DocumentMetadataFactoryInterface; use ApiPlatform\Core\Bridge\Symfony\Bundle\DependencyInjection\ApiPlatformExtension; use ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaRestrictionMetadataInterface; +use ApiPlatform\Core\Bridge\Symfony\Validator\ValidationGroupsGeneratorInterface; use ApiPlatform\Core\DataPersister\DataPersisterInterface; use ApiPlatform\Core\DataProvider\CollectionDataProviderInterface; use ApiPlatform\Core\DataProvider\ItemDataProviderInterface; @@ -1090,6 +1091,10 @@ private function getBaseContainerBuilderProphecy(array $doctrineIntegrationsToLo ->willReturn($this->childDefinitionProphecy)->shouldBeCalledTimes(1); $this->childDefinitionProphecy->setBindings(['$requestStack' => null])->shouldBeCalledTimes(1); + $containerBuilderProphecy->registerForAutoconfiguration(ValidationGroupsGeneratorInterface::class) + ->willReturn($this->childDefinitionProphecy)->shouldBeCalledTimes(1); + $this->childDefinitionProphecy->addTag('api_platform.validation_groups_generator')->willReturn($this->childDefinitionProphecy)->shouldBeCalledTimes(1); + $containerBuilderProphecy->registerForAutoconfiguration(PropertySchemaRestrictionMetadataInterface::class) ->willReturn($this->childDefinitionProphecy)->shouldBeCalledTimes(1); $this->childDefinitionProphecy->addTag('api_platform.metadata.property_schema_restriction')->shouldBeCalledTimes(1); @@ -1384,6 +1389,7 @@ private function getBaseContainerBuilderProphecy(array $doctrineIntegrationsToLo $containerBuilderProphecy->getDefinition('api_platform.doctrine.orm.listener.mercure.publish')->willReturn($definitionDummy); $containerBuilderProphecy->getDefinition('api_platform.doctrine_mongodb.odm.listener.mercure.publish')->willReturn($definitionDummy); $containerBuilderProphecy->getDefinition('api_platform.graphql.subscription.mercure_iri_generator')->willReturn($definitionDummy); + $this->childDefinitionProphecy->setPublic(true)->will(function () {}); return $containerBuilderProphecy; } diff --git a/tests/Bridge/Symfony/Validator/ValidatorTest.php b/tests/Bridge/Symfony/Validator/ValidatorTest.php index bee2588fcde..c5f02a64feb 100644 --- a/tests/Bridge/Symfony/Validator/ValidatorTest.php +++ b/tests/Bridge/Symfony/Validator/ValidatorTest.php @@ -14,6 +14,7 @@ namespace ApiPlatform\Core\Tests\Bridge\Symfony\Validator; use ApiPlatform\Core\Bridge\Symfony\Validator\Exception\ValidationException; +use ApiPlatform\Core\Bridge\Symfony\Validator\ValidationGroupsGeneratorInterface; use ApiPlatform\Core\Bridge\Symfony\Validator\Validator; use ApiPlatform\Core\Tests\Fixtures\DummyEntity; use PHPUnit\Framework\TestCase; @@ -74,26 +75,51 @@ public function testGetGroupsFromCallable() }]); } - public function testGetGroupsFromService() + public function testValidateGetGroupsFromService(): void { $data = new DummyEntity(); $constraintViolationListProphecy = $this->prophesize(ConstraintViolationListInterface::class); + $symfonyValidatorProphecy = $this->prophesize(SymfonyValidatorInterface::class); - $symfonyValidatorProphecy->validate($data, null, ['a', 'b', 'c'])->willReturn($constraintViolationListProphecy->reveal())->shouldBeCalled(); - $symfonyValidator = $symfonyValidatorProphecy->reveal(); + $symfonyValidatorProphecy->validate($data, null, ['a', 'b', 'c'])->willReturn($constraintViolationListProphecy)->shouldBeCalled(); $containerProphecy = $this->prophesize(ContainerInterface::class); - $containerProphecy->has('groups_builder')->willReturn(true)->shouldBeCalled(); + $containerProphecy->has('groups_builder')->willReturn(true); + $containerProphecy->get('groups_builder')->willReturn(new class() implements ValidationGroupsGeneratorInterface { + public function __invoke($data): array + { + return $data instanceof DummyEntity ? ['a', 'b', 'c'] : []; + } + }); + + $validator = new Validator($symfonyValidatorProphecy->reveal(), $containerProphecy->reveal()); + $validator->validate(new DummyEntity(), ['groups' => 'groups_builder']); + } + + /** + * @group legacy + * @expectedDeprecation Using a public validation groups generator service not implementing "ApiPlatform\Core\Bridge\Symfony\Validator\ValidationGroupsGeneratorInterface" is deprecated since 2.6 and will be removed in 3.0. + */ + public function testValidateGetGroupsFromLegacyService(): void + { + $data = new DummyEntity(); + + $constraintViolationListProphecy = $this->prophesize(ConstraintViolationListInterface::class); + + $symfonyValidatorProphecy = $this->prophesize(SymfonyValidatorInterface::class); + $symfonyValidatorProphecy->validate($data, null, ['a', 'b', 'c'])->willReturn($constraintViolationListProphecy); + + $containerProphecy = $this->prophesize(ContainerInterface::class); + $containerProphecy->has('groups_builder')->willReturn(true); $containerProphecy->get('groups_builder')->willReturn(new class() { public function __invoke($data): array { return $data instanceof DummyEntity ? ['a', 'b', 'c'] : []; } - } - )->shouldBeCalled(); + }); - $validator = new Validator($symfonyValidator, $containerProphecy->reveal()); + $validator = new Validator($symfonyValidatorProphecy->reveal(), $containerProphecy->reveal()); $validator->validate(new DummyEntity(), ['groups' => 'groups_builder']); } diff --git a/tests/Fixtures/TestBundle/Validator/DummyValidationGroupsGenerator.php b/tests/Fixtures/TestBundle/Validator/DummyValidationGroupsGenerator.php index 84044757cff..0f9f414d83c 100644 --- a/tests/Fixtures/TestBundle/Validator/DummyValidationGroupsGenerator.php +++ b/tests/Fixtures/TestBundle/Validator/DummyValidationGroupsGenerator.php @@ -13,11 +13,15 @@ namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Validator; +use ApiPlatform\Core\Bridge\Symfony\Validator\ValidationGroupsGeneratorInterface; use Symfony\Component\Validator\Constraints\GroupSequence; -class DummyValidationGroupsGenerator +final class DummyValidationGroupsGenerator implements ValidationGroupsGeneratorInterface { - public function __invoke() + /** + * {@inheritdoc} + */ + public function __invoke($object): GroupSequence { return new GroupSequence(['b', 'a']); } From 63d9deebf93d034365c6275a324233dfee337ab3 Mon Sep 17 00:00:00 2001 From: crosse Date: Tue, 9 Jul 2019 10:32:46 +0200 Subject: [PATCH 052/160] Add ErrorCodeSerializableInterface to add a possibility to add custom error codes during normalization of errors. #2922 --- .../ErrorCodeSerializableInterface.php | 25 ++++++++++++ src/JsonApi/Serializer/ErrorNormalizer.php | 7 +++- .../Serializer/ErrorNormalizerTrait.php | 15 +++++++ .../Serializer/ErrorNormalizerTest.php | 39 ++++++++++++++++++- .../Mock/Exception/ErrorCodeSerializable.php | 27 +++++++++++++ 5 files changed, 111 insertions(+), 2 deletions(-) create mode 100644 src/Exception/ErrorCodeSerializableInterface.php create mode 100644 tests/Mock/Exception/ErrorCodeSerializable.php diff --git a/src/Exception/ErrorCodeSerializableInterface.php b/src/Exception/ErrorCodeSerializableInterface.php new file mode 100644 index 00000000000..89c8face799 --- /dev/null +++ b/src/Exception/ErrorCodeSerializableInterface.php @@ -0,0 +1,25 @@ + + * + * 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\Exception; + +/** + * An exception which has a serializable application-specific error code. + */ +interface ErrorCodeSerializableInterface +{ + /** + * Gets the application-specific error code. + */ + public static function getErrorCode(): string; +} diff --git a/src/JsonApi/Serializer/ErrorNormalizer.php b/src/JsonApi/Serializer/ErrorNormalizer.php index 618147b17ac..4617cb1d345 100644 --- a/src/JsonApi/Serializer/ErrorNormalizer.php +++ b/src/JsonApi/Serializer/ErrorNormalizer.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Core\JsonApi\Serializer; +use ApiPlatform\Core\Exception\ErrorCodeSerializableInterface; use ApiPlatform\Core\Problem\Serializer\ErrorNormalizerTrait; use Symfony\Component\Debug\Exception\FlattenException as LegacyFlattenException; use Symfony\Component\ErrorHandler\Exception\FlattenException; @@ -42,13 +43,17 @@ public function __construct(bool $debug = false, array $defaultContext = []) $this->defaultContext = array_merge($this->defaultContext, $defaultContext); } - public function normalize($object, $format = null, array $context = []) + public function normalize($object, string $format = null, array $context = []) { $data = [ 'title' => $context[self::TITLE] ?? $this->defaultContext[self::TITLE], 'description' => $this->getErrorMessage($object, $context, $this->debug), ]; + if (null !== $errorCode = $this->getErrorCode($object)) { + $data['code'] = $errorCode; + } + if ($this->debug && null !== $trace = $object->getTrace()) { $data['trace'] = $trace; } diff --git a/src/Problem/Serializer/ErrorNormalizerTrait.php b/src/Problem/Serializer/ErrorNormalizerTrait.php index cca553eb3c7..fa89caeaeb6 100644 --- a/src/Problem/Serializer/ErrorNormalizerTrait.php +++ b/src/Problem/Serializer/ErrorNormalizerTrait.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Core\Problem\Serializer; +use ApiPlatform\Core\Exception\ErrorCodeSerializableInterface; use Symfony\Component\Debug\Exception\FlattenException as LegacyFlattenException; use Symfony\Component\ErrorHandler\Exception\FlattenException; use Symfony\Component\HttpFoundation\Response; @@ -36,4 +37,18 @@ private function getErrorMessage($object, array $context, bool $debug = false): return $message; } + + private function getErrorCode($object): ?string + { + $exceptionClass = get_class($object); + if ($object instanceof FlattenException || $object instanceof LegacyFlattenException) { + $exceptionClass = $object->getClass(); + } + + if (is_a($exceptionClass, ErrorCodeSerializableInterface::class, true)) { + return $exceptionClass::getErrorCode(); + } + + return null; + } } diff --git a/tests/JsonApi/Serializer/ErrorNormalizerTest.php b/tests/JsonApi/Serializer/ErrorNormalizerTest.php index c4bb2ced75e..bb6c17faf79 100644 --- a/tests/JsonApi/Serializer/ErrorNormalizerTest.php +++ b/tests/JsonApi/Serializer/ErrorNormalizerTest.php @@ -14,8 +14,9 @@ namespace ApiPlatform\Core\Tests\JsonApi\Serializer; use ApiPlatform\Core\JsonApi\Serializer\ErrorNormalizer; +use ApiPlatform\Core\Tests\Mock\Exception\ErrorCodeSerializable; use PHPUnit\Framework\TestCase; -use Symfony\Component\Debug\Exception\FlattenException; +use Symfony\Component\ErrorHandler\Exception\FlattenException; use Symfony\Component\HttpFoundation\Response; /** @@ -61,6 +62,42 @@ public function testNormalize($status, $originalMessage, $debug) $this->assertEquals($expected, $normalizer->normalize($exception, ErrorNormalizer::FORMAT, ['statusCode' => $status])); } + public function testNormalizeAnExceptionWithCustomErrorCode(): void + { + $status = Response::HTTP_BAD_REQUEST; + $originalMessage = 'my-message'; + $debug = false; + + $normalizer = new ErrorNormalizer($debug); + $exception = new ErrorCodeSerializable($originalMessage); + + $expected = [ + 'title' => 'An error occurred', + 'description' => 'my-message', + 'code' => ErrorCodeSerializable::getErrorCode(), + ]; + + $this->assertEquals($expected, $normalizer->normalize($exception, ErrorNormalizer::FORMAT, ['statusCode' => $status])); + } + + public function testNormalizeAFlattenExceptionWithCustomErrorCode(): void + { + $status = Response::HTTP_BAD_REQUEST; + $originalMessage = 'my-message'; + $debug = false; + + $normalizer = new ErrorNormalizer($debug); + $exception = FlattenException::create(new ErrorCodeSerializable($originalMessage), $status); + + $expected = [ + 'title' => 'An error occurred', + 'description' => 'my-message', + 'code' => ErrorCodeSerializable::getErrorCode(), + ]; + + $this->assertEquals($expected, $normalizer->normalize($exception, ErrorNormalizer::FORMAT, ['statusCode' => $status])); + } + public function errorProvider() { return [ diff --git a/tests/Mock/Exception/ErrorCodeSerializable.php b/tests/Mock/Exception/ErrorCodeSerializable.php new file mode 100644 index 00000000000..63bf1af86e8 --- /dev/null +++ b/tests/Mock/Exception/ErrorCodeSerializable.php @@ -0,0 +1,27 @@ + + * + * 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\Tests\Mock\Exception; + +use ApiPlatform\Core\Exception\ErrorCodeSerializableInterface; + +class ErrorCodeSerializable extends \Exception implements ErrorCodeSerializableInterface +{ + /** + * {@inheritdoc} + */ + public static function getErrorCode(): string + { + return '1234'; + } +} From 2bc6a2241aaef8f45f42e9f186b8acde09e21c35 Mon Sep 17 00:00:00 2001 From: Christoph Rosse Date: Thu, 5 Mar 2020 19:40:58 +0100 Subject: [PATCH 053/160] Update src/Problem/Serializer/ErrorNormalizerTrait.php Co-Authored-By: Teoh Han Hui --- src/Problem/Serializer/ErrorNormalizerTrait.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Problem/Serializer/ErrorNormalizerTrait.php b/src/Problem/Serializer/ErrorNormalizerTrait.php index fa89caeaeb6..fadb9ee9f88 100644 --- a/src/Problem/Serializer/ErrorNormalizerTrait.php +++ b/src/Problem/Serializer/ErrorNormalizerTrait.php @@ -40,9 +40,10 @@ private function getErrorMessage($object, array $context, bool $debug = false): private function getErrorCode($object): ?string { - $exceptionClass = get_class($object); if ($object instanceof FlattenException || $object instanceof LegacyFlattenException) { $exceptionClass = $object->getClass(); + } else { + $exceptionClass = get_class($object); } if (is_a($exceptionClass, ErrorCodeSerializableInterface::class, true)) { From 230d42ca8a4aaf7c3f97467159271a424b1e97e0 Mon Sep 17 00:00:00 2001 From: Christoph Rosse Date: Fri, 6 Mar 2020 12:09:33 +0100 Subject: [PATCH 054/160] Fix normalize method. --- src/JsonApi/Serializer/ErrorNormalizer.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/JsonApi/Serializer/ErrorNormalizer.php b/src/JsonApi/Serializer/ErrorNormalizer.php index 4617cb1d345..373ab137dc3 100644 --- a/src/JsonApi/Serializer/ErrorNormalizer.php +++ b/src/JsonApi/Serializer/ErrorNormalizer.php @@ -43,7 +43,7 @@ public function __construct(bool $debug = false, array $defaultContext = []) $this->defaultContext = array_merge($this->defaultContext, $defaultContext); } - public function normalize($object, string $format = null, array $context = []) + public function normalize($object, $format = null, array $context = []) { $data = [ 'title' => $context[self::TITLE] ?? $this->defaultContext[self::TITLE], From 48beeb47f29270590eb795ab113c477134799c56 Mon Sep 17 00:00:00 2001 From: Christoph Rosse Date: Mon, 9 Mar 2020 08:48:33 +0100 Subject: [PATCH 055/160] Cs fixes. --- src/JsonApi/Serializer/ErrorNormalizer.php | 1 - src/Problem/Serializer/ErrorNormalizerTrait.php | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/JsonApi/Serializer/ErrorNormalizer.php b/src/JsonApi/Serializer/ErrorNormalizer.php index 373ab137dc3..6ba2d582728 100644 --- a/src/JsonApi/Serializer/ErrorNormalizer.php +++ b/src/JsonApi/Serializer/ErrorNormalizer.php @@ -13,7 +13,6 @@ namespace ApiPlatform\Core\JsonApi\Serializer; -use ApiPlatform\Core\Exception\ErrorCodeSerializableInterface; use ApiPlatform\Core\Problem\Serializer\ErrorNormalizerTrait; use Symfony\Component\Debug\Exception\FlattenException as LegacyFlattenException; use Symfony\Component\ErrorHandler\Exception\FlattenException; diff --git a/src/Problem/Serializer/ErrorNormalizerTrait.php b/src/Problem/Serializer/ErrorNormalizerTrait.php index fadb9ee9f88..13bc6e3e6d5 100644 --- a/src/Problem/Serializer/ErrorNormalizerTrait.php +++ b/src/Problem/Serializer/ErrorNormalizerTrait.php @@ -43,7 +43,7 @@ private function getErrorCode($object): ?string if ($object instanceof FlattenException || $object instanceof LegacyFlattenException) { $exceptionClass = $object->getClass(); } else { - $exceptionClass = get_class($object); + $exceptionClass = \get_class($object); } if (is_a($exceptionClass, ErrorCodeSerializableInterface::class, true)) { From 75cc7457570dfed8aa68ef64d75b625ea7762685 Mon Sep 17 00:00:00 2001 From: Christoph Rosse Date: Mon, 9 Mar 2020 09:31:00 +0100 Subject: [PATCH 056/160] Fall back to previously used ErrorException. --- tests/JsonApi/Serializer/ErrorNormalizerTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/JsonApi/Serializer/ErrorNormalizerTest.php b/tests/JsonApi/Serializer/ErrorNormalizerTest.php index bb6c17faf79..dc9e68b4420 100644 --- a/tests/JsonApi/Serializer/ErrorNormalizerTest.php +++ b/tests/JsonApi/Serializer/ErrorNormalizerTest.php @@ -16,7 +16,7 @@ use ApiPlatform\Core\JsonApi\Serializer\ErrorNormalizer; use ApiPlatform\Core\Tests\Mock\Exception\ErrorCodeSerializable; use PHPUnit\Framework\TestCase; -use Symfony\Component\ErrorHandler\Exception\FlattenException; +use Symfony\Component\Debug\Exception\FlattenException; use Symfony\Component\HttpFoundation\Response; /** From c6055bee601173477c4fb99a3f92b6e7fdc526fb Mon Sep 17 00:00:00 2001 From: Jocelyn Fournier Date: Tue, 10 Mar 2020 09:19:27 +0100 Subject: [PATCH 057/160] Update src/HttpCache/VarnishPurger.php Co-Authored-By: Teoh Han Hui --- src/HttpCache/VarnishPurger.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/HttpCache/VarnishPurger.php b/src/HttpCache/VarnishPurger.php index ec226f5102c..35bb7405ede 100644 --- a/src/HttpCache/VarnishPurger.php +++ b/src/HttpCache/VarnishPurger.php @@ -48,7 +48,7 @@ public function __construct(array $clients, int $maxHeaderLength = 7500) * * @return int Number of tags per tag invalidation request */ - private function determineTagsPerHeader($escapedTags, $glue) + private function determineTagsPerHeader(array $escapedTags, string $glue): int { if (mb_strlen(implode($glue, $escapedTags)) < $this->maxHeaderLength) { return \count($escapedTags); From b34f1f7360e4dbf3c47fa1ef92e1d0a317ef244e Mon Sep 17 00:00:00 2001 From: momozor Date: Thu, 12 Mar 2020 19:16:41 +0800 Subject: [PATCH 058/160] add stale-while-validate and stale-if-error cache-control extensions --- src/HttpCache/EventListener/AddHeadersListener.php | 14 +++++++++++++- .../EventListener/AddHeadersListenerTest.php | 14 +++++++------- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/src/HttpCache/EventListener/AddHeadersListener.php b/src/HttpCache/EventListener/AddHeadersListener.php index 9ea49d7f951..fec4997f8ee 100644 --- a/src/HttpCache/EventListener/AddHeadersListener.php +++ b/src/HttpCache/EventListener/AddHeadersListener.php @@ -32,8 +32,10 @@ final class AddHeadersListener private $vary; private $public; private $resourceMetadataFactory; + private $staleWhileRevalidate; + private $staleIfError; - public function __construct(bool $etag = false, int $maxAge = null, int $sharedMaxAge = null, array $vary = null, bool $public = null, ResourceMetadataFactoryInterface $resourceMetadataFactory = null) + public function __construct(bool $etag = false, int $maxAge = null, int $sharedMaxAge = null, array $vary = null, bool $public = null, ResourceMetadataFactoryInterface $resourceMetadataFactory = null, int $staleWhileRevalidate = null, int $staleIfError = null) { $this->etag = $etag; $this->maxAge = $maxAge; @@ -41,6 +43,8 @@ public function __construct(bool $etag = false, int $maxAge = null, int $sharedM $this->vary = $vary; $this->public = $public; $this->resourceMetadataFactory = $resourceMetadataFactory; + $this->staleWhileRevalidate = $staleWhileRevalidate; + $this->staleIfError = $staleIfError; } public function onKernelResponse(ResponseEvent $event): void @@ -83,5 +87,13 @@ public function onKernelResponse(ResponseEvent $event): void if (null !== $this->public && !$response->headers->hasCacheControlDirective('public')) { $this->public ? $response->setPublic() : $response->setPrivate(); } + + if (null !== ($staleWhileRevalidate = $resourceCacheHeaders['stale_while_revalidate'] ?? $this->staleWhileRevalidate) && !$response->headers->hasCacheControlDirective('stale-while-revalidate')) { + $response->headers->addCacheControlDirective('stale-while-revalidate', $staleWhileRevalidate); + } + + if (null !== ($staleIfError = $resourceCacheHeaders['stale_if_error'] ?? $this->staleIfError) && !$response->headers->hasCacheControlDirective('stale-if-error')) { + $response->headers->addCacheControlDirective('stale-if-error', $staleIfError); + } } } diff --git a/tests/HttpCache/EventListener/AddHeadersListenerTest.php b/tests/HttpCache/EventListener/AddHeadersListenerTest.php index 2e5e2af634a..9206b96dc31 100644 --- a/tests/HttpCache/EventListener/AddHeadersListenerTest.php +++ b/tests/HttpCache/EventListener/AddHeadersListenerTest.php @@ -102,11 +102,11 @@ public function testAddHeaders() $factory = $this->prophesize(ResourceMetadataFactoryInterface::class); $factory->create(Dummy::class)->willReturn(new ResourceMetadata())->shouldBeCalled(); - $listener = new AddHeadersListener(true, 100, 200, ['Accept', 'Accept-Encoding'], true, $factory->reveal()); + $listener = new AddHeadersListener(true, 100, 200, ['Accept', 'Accept-Encoding'], true, $factory->reveal(), 15, 30); $listener->onKernelResponse($event->reveal()); $this->assertSame('"9893532233caff98cd083a116b013c0b"', $response->getEtag()); - $this->assertSame('max-age=100, public, s-maxage=200', $response->headers->get('Cache-Control')); + $this->assertSame('max-age=100, public, s-maxage=200, stale-if-error=30, stale-while-revalidate=15', $response->headers->get('Cache-Control')); $this->assertSame(['Accept', 'Cookie', 'Accept-Encoding'], $response->getVary()); } @@ -126,11 +126,11 @@ public function testDoNotSetHeaderWhenAlreadySet() $factory = $this->prophesize(ResourceMetadataFactoryInterface::class); $factory->create(Dummy::class)->willReturn(new ResourceMetadata())->shouldBeCalled(); - $listener = new AddHeadersListener(true, 100, 200, ['Accept', 'Accept-Encoding'], true, $factory->reveal()); + $listener = new AddHeadersListener(true, 100, 200, ['Accept', 'Accept-Encoding'], true, $factory->reveal(), 15, 30); $listener->onKernelResponse($event->reveal()); $this->assertSame('"etag"', $response->getEtag()); - $this->assertSame('max-age=300, public, s-maxage=400', $response->headers->get('Cache-Control')); + $this->assertSame('max-age=300, public, s-maxage=400, stale-if-error=30, stale-while-revalidate=15', $response->headers->get('Cache-Control')); $this->assertSame(['Accept', 'Cookie', 'Accept-Encoding'], $response->getVary()); } @@ -143,14 +143,14 @@ public function testSetHeadersFromResourceMetadata() $event->getRequest()->willReturn($request)->shouldBeCalled(); $event->getResponse()->willReturn($response)->shouldBeCalled(); - $metadata = new ResourceMetadata(null, null, null, null, null, ['cache_headers' => ['max_age' => 123, 'shared_max_age' => 456, 'vary' => ['Vary-1', 'Vary-2']]]); + $metadata = new ResourceMetadata(null, null, null, null, null, ['cache_headers' => ['max_age' => 123, 'shared_max_age' => 456, 'stale_while_revalidate' => 928, 'stale_if_error' => 70, 'vary' => ['Vary-1', 'Vary-2']]]); $factory = $this->prophesize(ResourceMetadataFactoryInterface::class); $factory->create(Dummy::class)->willReturn($metadata)->shouldBeCalled(); - $listener = new AddHeadersListener(true, 100, 200, ['Accept', 'Accept-Encoding'], true, $factory->reveal()); + $listener = new AddHeadersListener(true, 100, 200, ['Accept', 'Accept-Encoding'], true, $factory->reveal(), 15, 30); $listener->onKernelResponse($event->reveal()); - $this->assertSame('max-age=123, public, s-maxage=456', $response->headers->get('Cache-Control')); + $this->assertSame('max-age=123, public, s-maxage=456, stale-if-error=70, stale-while-revalidate=928', $response->headers->get('Cache-Control')); $this->assertSame(['Vary-1', 'Vary-2'], $response->getVary()); } } From efc65ff166fdd60e41b3628f3fa70b283a5ef1de Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 16 Mar 2020 14:52:48 +0100 Subject: [PATCH 059/160] Fix tests --- tests/Swagger/Serializer/DocumentationNormalizerV2Test.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Swagger/Serializer/DocumentationNormalizerV2Test.php b/tests/Swagger/Serializer/DocumentationNormalizerV2Test.php index 705045e0d01..61cfe4d4d35 100644 --- a/tests/Swagger/Serializer/DocumentationNormalizerV2Test.php +++ b/tests/Swagger/Serializer/DocumentationNormalizerV2Test.php @@ -2939,14 +2939,14 @@ public function testNormalizeWithDefaultProperty($expectedDefault, $expectedExam $documentation = new Documentation(new ResourceNameCollection([DummyPropertyWithDefaultValue::class])); $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); - $propertyNameCollectionFactoryProphecy->create(DummyPropertyWithDefaultValue::class, [])->shouldBeCalled()->willReturn(new PropertyNameCollection(['foo'])); + $propertyNameCollectionFactoryProphecy->create(DummyPropertyWithDefaultValue::class, Argument::any())->shouldBeCalled()->willReturn(new PropertyNameCollection(['foo'])); $dummyMetadata = new ResourceMetadata('DummyPropertyWithDefaultValue', null, null, ['get' => ['method' => 'GET']]); $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); $resourceMetadataFactoryProphecy->create(DummyPropertyWithDefaultValue::class)->shouldBeCalled()->willReturn($dummyMetadata); $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $propertyMetadataFactoryProphecy->create(DummyPropertyWithDefaultValue::class, 'foo')->shouldBeCalled()->willReturn($propertyMetadata); + $propertyMetadataFactoryProphecy->create(DummyPropertyWithDefaultValue::class, 'foo', Argument::any())->shouldBeCalled()->willReturn($propertyMetadata); $operationPathResolver = new CustomOperationPathResolver(new OperationPathResolver(new UnderscorePathSegmentNameGenerator())); From fa9eb8c0b88151f1ce18be136496c4e98a3afdf1 Mon Sep 17 00:00:00 2001 From: soyuka Date: Sun, 29 Mar 2020 16:12:13 +0200 Subject: [PATCH 060/160] Fix symfony 5.1 exception test --- .../Symfony/Bundle/DependencyInjection/ConfigurationTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Bridge/Symfony/Bundle/DependencyInjection/ConfigurationTest.php b/tests/Bridge/Symfony/Bundle/DependencyInjection/ConfigurationTest.php index c27bdeafa74..57510a68ca1 100644 --- a/tests/Bridge/Symfony/Bundle/DependencyInjection/ConfigurationTest.php +++ b/tests/Bridge/Symfony/Bundle/DependencyInjection/ConfigurationTest.php @@ -290,7 +290,7 @@ public function invalidHttpStatusCodeValueProvider() public function testExceptionToStatusConfigWithInvalidHttpStatusCodeValue($invalidHttpStatusCodeValue) { $this->expectException(InvalidTypeException::class); - $this->expectExceptionMessageRegExp('/Invalid type for path "api_platform\\.exception_to_status\\.Exception". Expected int, but got .+\\./'); + $this->expectExceptionMessageRegExp('/Invalid type for path "api_platform\\.exception_to_status\\.Exception". Expected "?int"?, but got "?.+"?\./'); $this->processor->processConfiguration($this->configuration, [ 'api_platform' => [ From 440617413cf60cb4ab9acb6f8ac24602b37d9953 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Dunglas?= Date: Tue, 19 Nov 2019 07:44:33 +0100 Subject: [PATCH 061/160] Remove http_cache.invalidation deprecation and fix some remaining deprec --- .../DependencyInjection/Configuration.php | 1 - tests/Fixtures/app/config/config_common.yml | 24 +++++++++---------- 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php b/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php index 237318caa33..1373719287a 100644 --- a/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php +++ b/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php @@ -366,7 +366,6 @@ private function addHttpCacheSection(ArrayNodeDefinition $rootNode): void ->end() ->booleanNode('public')->defaultNull()->info('To make all responses public by default.')->end() ->arrayNode('invalidation') - ->setDeprecated('The use of the `http_cache.invalidation` has been deprecated in 2.6 and will be removed in 3.0. Use `defaults.cache_headers.invalidation` instead.') ->info('Enable the tags-based cache invalidation system.') ->canBeEnabled() ->children() diff --git a/tests/Fixtures/app/config/config_common.yml b/tests/Fixtures/app/config/config_common.yml index 94c452a69bf..ff7381f0521 100644 --- a/tests/Fixtures/app/config/config_common.yml +++ b/tests/Fixtures/app/config/config_common.yml @@ -58,14 +58,6 @@ api_platform: nesting_separator: __ name_converter: 'app.name_converter' enable_fos_user: true - collection: - order_parameter_name: 'order' - order: 'ASC' - pagination: - client_enabled: true - client_items_per_page: true - client_partial: true - items_per_page: 3 exception_to_status: Symfony\Component\Serializer\Exception\ExceptionInterface: !php/const Symfony\Component\HttpFoundation\Response::HTTP_BAD_REQUEST ApiPlatform\Core\Exception\InvalidArgumentException: !php/const Symfony\Component\HttpFoundation\Response::HTTP_BAD_REQUEST @@ -73,10 +65,18 @@ api_platform: http_cache: invalidation: enabled: true - max_age: 60 - shared_max_age: 3600 - vary: ['Accept', 'Cookie'] - public: true + defaults: + pagination_client_enabled: true + pagination_client_items_per_page: true + pagination_client_partial: true + pagination_items_per_page: 3 + order_parameter_name: 'order' + order: 'ASC' + cache_headers: + max_age: 60 + shared_max_age: 3600 + vary: ['Accept', 'Cookie'] + public: true parameters: container.autowiring.strict_mode: true From 690c28cec01aa8dc361946dce19137208b35b10b Mon Sep 17 00:00:00 2001 From: soyuka Date: Sun, 29 Mar 2020 23:31:26 +0200 Subject: [PATCH 062/160] Fix pagination options with defaults --- .../ApiPlatformExtension.php | 54 ++++++++++++------- tests/Fixtures/app/config/config_common.yml | 17 +++--- .../AnnotationResourceMetadataFactoryTest.php | 2 + 3 files changed, 46 insertions(+), 27 deletions(-) diff --git a/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index d7c7d5d53fd..5cc4f107ce6 100644 --- a/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -169,24 +169,24 @@ private function registerCommonConfiguration(ContainerBuilder $container, array $container->setParameter('api_platform.collection.exists_parameter_name', $config['collection']['exists_parameter_name']); $container->setParameter('api_platform.collection.order', $config['collection']['order']); $container->setParameter('api_platform.collection.order_parameter_name', $config['collection']['order_parameter_name']); - $container->setParameter('api_platform.collection.pagination.enabled', $this->isConfigEnabled($container, $config['collection']['pagination'])); - $container->setParameter('api_platform.collection.pagination.partial', $config['collection']['pagination']['partial']); - $container->setParameter('api_platform.collection.pagination.client_enabled', $config['collection']['pagination']['client_enabled']); - $container->setParameter('api_platform.collection.pagination.client_items_per_page', $config['collection']['pagination']['client_items_per_page']); - $container->setParameter('api_platform.collection.pagination.client_partial', $config['collection']['pagination']['client_partial']); - $container->setParameter('api_platform.collection.pagination.items_per_page', $config['collection']['pagination']['items_per_page']); - $container->setParameter('api_platform.collection.pagination.maximum_items_per_page', $config['collection']['pagination']['maximum_items_per_page']); - $container->setParameter('api_platform.collection.pagination.page_parameter_name', $config['collection']['pagination']['page_parameter_name']); - $container->setParameter('api_platform.collection.pagination.enabled_parameter_name', $config['collection']['pagination']['enabled_parameter_name']); - $container->setParameter('api_platform.collection.pagination.items_per_page_parameter_name', $config['collection']['pagination']['items_per_page_parameter_name']); - $container->setParameter('api_platform.collection.pagination.partial_parameter_name', $config['collection']['pagination']['partial_parameter_name']); - $container->setParameter('api_platform.collection.pagination', $config['collection']['pagination']); - $container->setParameter('api_platform.http_cache.etag', $config['http_cache']['etag']); - $container->setParameter('api_platform.http_cache.max_age', $config['http_cache']['max_age']); - $container->setParameter('api_platform.http_cache.shared_max_age', $config['http_cache']['shared_max_age']); - $container->setParameter('api_platform.http_cache.vary', $config['http_cache']['vary']); - $container->setParameter('api_platform.http_cache.public', $config['http_cache']['public']); - $container->setParameter('api_platform.http_cache.invalidation.max_header_length', $config['http_cache']['invalidation']['max_header_length']); + $container->setParameter('api_platform.collection.pagination.enabled', $this->isConfigEnabled($container, $config['defaults']['pagination_enabled'] ?? $config['collection']['pagination'])); + $container->setParameter('api_platform.collection.pagination.partial', $config['defaults']['pagination_partial'] ?? $config['collection']['pagination']['partial']); + $container->setParameter('api_platform.collection.pagination.client_enabled', $config['defaults']['pagination_client_enabled'] ?? $config['collection']['pagination']['client_enabled']); + $container->setParameter('api_platform.collection.pagination.client_items_per_page', $config['defaults']['pagination_client_items_per_page'] ?? $config['collection']['pagination']['client_items_per_page']); + $container->setParameter('api_platform.collection.pagination.client_partial', $config['defaults']['pagination_client_partial'] ?? $config['collection']['pagination']['client_partial']); + $container->setParameter('api_platform.collection.pagination.items_per_page', $config['defaults']['pagination_items_per_page'] ?? $config['collection']['pagination']['items_per_page']); + $container->setParameter('api_platform.collection.pagination.maximum_items_per_page', $config['defaults']['pagination_maximum_items_per_page'] ?? $config['collection']['pagination']['maximum_items_per_page']); + $container->setParameter('api_platform.collection.pagination.page_parameter_name', $config['defaults']['pagination_page_parameter_name'] ?? $config['collection']['pagination']['page_parameter_name']); + $container->setParameter('api_platform.collection.pagination.enabled_parameter_name', $config['defaults']['pagination_enabled_parameter_name'] ?? $config['collection']['pagination']['enabled_parameter_name']); + $container->setParameter('api_platform.collection.pagination.items_per_page_parameter_name', $config['defaults']['pagination_items_per_page_parameter_name'] ?? $config['collection']['pagination']['items_per_page_parameter_name']); + $container->setParameter('api_platform.collection.pagination.partial_parameter_name', $config['defaults']['pagination_partial_parameter_name'] ?? $config['collection']['pagination']['partial_parameter_name']); + $container->setParameter('api_platform.collection.pagination', $this->getPaginationDefaults($config['defaults'], $config['collection']['pagination'])); + $container->setParameter('api_platform.http_cache.etag', $config['defaults']['cache_headers']['etag'] ?? $config['http_cache']['etag']); + $container->setParameter('api_platform.http_cache.max_age', $config['defaults']['cache_headers']['max_age'] ?? $config['http_cache']['max_age']); + $container->setParameter('api_platform.http_cache.shared_max_age', $config['defaults']['cache_headers']['shared_max_age'] ?? $config['http_cache']['shared_max_age']); + $container->setParameter('api_platform.http_cache.vary', $config['defaults']['cache_headers']['vary'] ?? $config['http_cache']['vary']); + $container->setParameter('api_platform.http_cache.public', $config['defaults']['cache_headers']['public'] ?? $config['http_cache']['public']); + $container->setParameter('api_platform.http_cache.invalidation.max_header_length', $config['defaults']['cache_headers']['invalidation']['max_header_length'] ?? $config['http_cache']['invalidation']['max_header_length']); $container->setAlias('api_platform.operation_path_resolver.default', $config['default_operation_path_resolver']); $container->setAlias('api_platform.path_segment_name_generator', $config['path_segment_name_generator']); @@ -197,6 +197,24 @@ private function registerCommonConfiguration(ContainerBuilder $container, array $container->setParameter('api_platform.defaults', $this->normalizeDefaults($config['defaults'] ?? [])); } + /** + * This method will be removed in 3.0 when "defaults" will be the regular configuration path for the pagination. + */ + private function getPaginationDefaults(array $defaults, array $collectionPaginationConfiguration): array + { + $paginationOptions = []; + + foreach ($defaults as $key => $value) { + if (0 !== strpos($key, 'pagination_')) { + continue; + } + + $paginationOptions[str_replace('pagination_', '', $key)] = $value; + } + + return array_merge($collectionPaginationConfiguration, $paginationOptions); + } + private function normalizeDefaults(array $defaults): array { $normalizedDefaults = ['attributes' => []]; diff --git a/tests/Fixtures/app/config/config_common.yml b/tests/Fixtures/app/config/config_common.yml index ff7381f0521..caf23a48d8c 100644 --- a/tests/Fixtures/app/config/config_common.yml +++ b/tests/Fixtures/app/config/config_common.yml @@ -70,13 +70,12 @@ api_platform: pagination_client_items_per_page: true pagination_client_partial: true pagination_items_per_page: 3 - order_parameter_name: 'order' - order: 'ASC' + order: 'ASC' cache_headers: - max_age: 60 - shared_max_age: 3600 - vary: ['Accept', 'Cookie'] - public: true + max_age: 60 + shared_max_age: 3600 + vary: ['Accept', 'Cookie'] + public: true parameters: container.autowiring.strict_mode: true @@ -144,11 +143,11 @@ services: ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter\RequiredAllowEmptyFilter: arguments: [ '@doctrine' ] tags: [ 'api_platform.filter' ] - + ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter\BoundsFilter: arguments: [ '@doctrine' ] tags: [ 'api_platform.filter' ] - + ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter\LengthFilter: arguments: [ '@doctrine' ] tags: [ 'api_platform.filter' ] @@ -156,7 +155,7 @@ services: ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter\PatternFilter: arguments: [ '@doctrine' ] tags: [ 'api_platform.filter' ] - + ApiPlatform\Core\Tests\Fixtures\TestBundle\Controller\: resource: '../../TestBundle/Controller' tags: ['controller.service_arguments'] diff --git a/tests/Metadata/Resource/Factory/AnnotationResourceMetadataFactoryTest.php b/tests/Metadata/Resource/Factory/AnnotationResourceMetadataFactoryTest.php index 744e3e66053..c53dfa048c6 100644 --- a/tests/Metadata/Resource/Factory/AnnotationResourceMetadataFactoryTest.php +++ b/tests/Metadata/Resource/Factory/AnnotationResourceMetadataFactoryTest.php @@ -62,6 +62,7 @@ public function testCreateWithDefaults() $annotation = new ApiResource([ 'itemOperations' => ['get', 'delete'], 'attributes' => [ + 'pagination_client_enabled' => true, 'pagination_maximum_items_per_page' => 10, ], ]); @@ -74,6 +75,7 @@ public function testCreateWithDefaults() $this->assertEquals('CHANGEME!', $metadata->getDescription()); $this->assertEquals(['get'], $metadata->getCollectionOperations()); $this->assertEquals(['get', 'delete'], $metadata->getItemOperations()); + $this->assertTrue($metadata->getAttribute('pagination_client_enabled')); $this->assertEquals(4, $metadata->getAttribute('pagination_items_per_page')); $this->assertEquals(10, $metadata->getAttribute('pagination_maximum_items_per_page')); } From 48a489e859eb28e831455899f8bc031d43bbf769 Mon Sep 17 00:00:00 2001 From: Ashura Date: Thu, 9 Apr 2020 17:00:40 +0200 Subject: [PATCH 063/160] Add files to test client --- src/Bridge/Symfony/Bundle/Test/Client.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Bridge/Symfony/Bundle/Test/Client.php b/src/Bridge/Symfony/Bundle/Test/Client.php index 830a735fe23..6ae7b343de8 100644 --- a/src/Bridge/Symfony/Bundle/Test/Client.php +++ b/src/Bridge/Symfony/Bundle/Test/Client.php @@ -84,7 +84,7 @@ public function setDefaultOptions(array $defaultOptions): void * * @return Response */ - public function request(string $method, string $url, array $options = []): ResponseInterface + public function request(string $method, string $url, array $options = [], array $files = []): ResponseInterface { $basic = $options['auth_basic'] ?? null; [$url, $options] = self::prepareRequest($method, $url, $options, $this->defaultOptions); @@ -121,7 +121,7 @@ public function request(string $method, string $url, array $options = []): Respo 'url' => $resolvedUrl, 'primary_port' => 'http:' === $url['scheme'] ? 80 : 443, ]; - $this->kernelBrowser->request($method, $resolvedUrl, [], [], $server, $options['body'] ?? null); + $this->kernelBrowser->request($method, $resolvedUrl, [], $files, $server, $options['body'] ?? null); return $this->response = new Response($this->kernelBrowser->getResponse(), $this->kernelBrowser->getInternalResponse(), $info); } From 3492922c2733de934cf4798893d907755640869d Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 9 Apr 2020 16:56:03 +0200 Subject: [PATCH 064/160] Fix order being a string --- src/Bridge/Doctrine/MongoDbOdm/Extension/OrderExtension.php | 2 +- src/Bridge/Doctrine/Orm/Extension/OrderExtension.php | 2 +- .../Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Bridge/Doctrine/MongoDbOdm/Extension/OrderExtension.php b/src/Bridge/Doctrine/MongoDbOdm/Extension/OrderExtension.php index d335235aff4..68302cf142d 100644 --- a/src/Bridge/Doctrine/MongoDbOdm/Extension/OrderExtension.php +++ b/src/Bridge/Doctrine/MongoDbOdm/Extension/OrderExtension.php @@ -54,7 +54,7 @@ public function applyToCollection(Builder $aggregationBuilder, string $resourceC $identifiers = $classMetaData->getIdentifier(); if (null !== $this->resourceMetadataFactory) { $defaultOrder = $this->resourceMetadataFactory->create($resourceClass)->getAttribute('order'); - if (null !== $defaultOrder) { + if (\is_array($defaultOrder)) { foreach ($defaultOrder as $field => $order) { if (\is_int($field)) { // Default direction diff --git a/src/Bridge/Doctrine/Orm/Extension/OrderExtension.php b/src/Bridge/Doctrine/Orm/Extension/OrderExtension.php index 1d2b1e3f469..0cc5a94d125 100644 --- a/src/Bridge/Doctrine/Orm/Extension/OrderExtension.php +++ b/src/Bridge/Doctrine/Orm/Extension/OrderExtension.php @@ -52,7 +52,7 @@ public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGenerator $identifiers = $classMetaData->getIdentifier(); if (null !== $this->resourceMetadataFactory) { $defaultOrder = $this->resourceMetadataFactory->create($resourceClass)->getAttribute('order'); - if (null !== $defaultOrder) { + if (\is_array($defaultOrder)) { foreach ($defaultOrder as $field => $order) { if (\is_int($field)) { // Default direction diff --git a/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index 5cc4f107ce6..6fc9f146da4 100644 --- a/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -167,7 +167,7 @@ private function registerCommonConfiguration(ContainerBuilder $container, array $container->setParameter('api_platform.eager_loading.fetch_partial', $config['eager_loading']['fetch_partial']); $container->setParameter('api_platform.eager_loading.force_eager', $config['eager_loading']['force_eager']); $container->setParameter('api_platform.collection.exists_parameter_name', $config['collection']['exists_parameter_name']); - $container->setParameter('api_platform.collection.order', $config['collection']['order']); + $container->setParameter('api_platform.collection.order', $config['defaults']['order'] ?? $config['collection']['order']); $container->setParameter('api_platform.collection.order_parameter_name', $config['collection']['order_parameter_name']); $container->setParameter('api_platform.collection.pagination.enabled', $this->isConfigEnabled($container, $config['defaults']['pagination_enabled'] ?? $config['collection']['pagination'])); $container->setParameter('api_platform.collection.pagination.partial', $config['defaults']['pagination_partial'] ?? $config['collection']['pagination']['partial']); From 464244462e5479fe503da9104a378edf079a8ef6 Mon Sep 17 00:00:00 2001 From: Ashura Date: Thu, 9 Apr 2020 17:40:45 +0200 Subject: [PATCH 065/160] Respect method signature in favor of interface --- src/Bridge/Symfony/Bundle/Test/Client.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Bridge/Symfony/Bundle/Test/Client.php b/src/Bridge/Symfony/Bundle/Test/Client.php index 6ae7b343de8..ffc54885aa4 100644 --- a/src/Bridge/Symfony/Bundle/Test/Client.php +++ b/src/Bridge/Symfony/Bundle/Test/Client.php @@ -84,7 +84,7 @@ public function setDefaultOptions(array $defaultOptions): void * * @return Response */ - public function request(string $method, string $url, array $options = [], array $files = []): ResponseInterface + public function request(string $method, string $url, array $options = []): ResponseInterface { $basic = $options['auth_basic'] ?? null; [$url, $options] = self::prepareRequest($method, $url, $options, $this->defaultOptions); @@ -121,7 +121,8 @@ public function request(string $method, string $url, array $options = [], array 'url' => $resolvedUrl, 'primary_port' => 'http:' === $url['scheme'] ? 80 : 443, ]; - $this->kernelBrowser->request($method, $resolvedUrl, [], $files, $server, $options['body'] ?? null); + $extra = $options['extra'] ?? []; + $this->kernelBrowser->request($method, $resolvedUrl, [], $extra['files'] ?? [], $server, $options['body'] ?? null); return $this->response = new Response($this->kernelBrowser->getResponse(), $this->kernelBrowser->getInternalResponse(), $info); } From 2301538c67b442d1d42560fce21c7c10a32e9eca Mon Sep 17 00:00:00 2001 From: Ashura Date: Thu, 9 Apr 2020 17:48:27 +0200 Subject: [PATCH 066/160] Add missing default --- src/Bridge/Symfony/Bundle/Test/Client.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Bridge/Symfony/Bundle/Test/Client.php b/src/Bridge/Symfony/Bundle/Test/Client.php index ffc54885aa4..98a2013ca52 100644 --- a/src/Bridge/Symfony/Bundle/Test/Client.php +++ b/src/Bridge/Symfony/Bundle/Test/Client.php @@ -44,6 +44,7 @@ final class Client implements HttpClientInterface 'body' => '', 'json' => null, 'base_uri' => 'http://example.com', + 'extra' => [], ]; private $kernelBrowser; From fbfa61558ede5808aba0deb6f1ca13d4e4e81b80 Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 9 Apr 2020 19:28:22 +0200 Subject: [PATCH 067/160] FOSUserBundle deprecates GroupableInterface used by our BaseModel --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 490f91a8104..afae1b511d1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2008,7 +2008,7 @@ jobs: fail-fast: false timeout-minutes: 20 env: - SYMFONY_DEPRECATIONS_HELPER: max[total]=5 # 5 deprecation notices from FOSUserBundle + SYMFONY_DEPRECATIONS_HELPER: max[total]=8 # 5 deprecation notices from FOSUserBundle steps: - name: Checkout uses: actions/checkout@v1 From 45e58dd0958a613e6dd4d6f5822d718c2635e33b Mon Sep 17 00:00:00 2001 From: Ashura Date: Fri, 10 Apr 2020 08:28:55 +0200 Subject: [PATCH 068/160] Update passing of files according to feedback --- src/Bridge/Symfony/Bundle/Test/Client.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Bridge/Symfony/Bundle/Test/Client.php b/src/Bridge/Symfony/Bundle/Test/Client.php index 98a2013ca52..d1250451923 100644 --- a/src/Bridge/Symfony/Bundle/Test/Client.php +++ b/src/Bridge/Symfony/Bundle/Test/Client.php @@ -122,8 +122,7 @@ public function request(string $method, string $url, array $options = []): Respo 'url' => $resolvedUrl, 'primary_port' => 'http:' === $url['scheme'] ? 80 : 443, ]; - $extra = $options['extra'] ?? []; - $this->kernelBrowser->request($method, $resolvedUrl, [], $extra['files'] ?? [], $server, $options['body'] ?? null); + $this->kernelBrowser->request($method, $resolvedUrl, [], $options['extra']['files'] ?? [], $server, $options['body'] ?? null); return $this->response = new Response($this->kernelBrowser->getResponse(), $this->kernelBrowser->getInternalResponse(), $info); } From 3d28753a0bebc9f0971fc731162b025f509aba13 Mon Sep 17 00:00:00 2001 From: Ashura Date: Sat, 11 Apr 2020 19:17:16 +0200 Subject: [PATCH 069/160] Add request parameters to client. --- src/Bridge/Symfony/Bundle/Test/Client.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Bridge/Symfony/Bundle/Test/Client.php b/src/Bridge/Symfony/Bundle/Test/Client.php index d1250451923..270d560c8e0 100644 --- a/src/Bridge/Symfony/Bundle/Test/Client.php +++ b/src/Bridge/Symfony/Bundle/Test/Client.php @@ -122,7 +122,7 @@ public function request(string $method, string $url, array $options = []): Respo 'url' => $resolvedUrl, 'primary_port' => 'http:' === $url['scheme'] ? 80 : 443, ]; - $this->kernelBrowser->request($method, $resolvedUrl, [], $options['extra']['files'] ?? [], $server, $options['body'] ?? null); + $this->kernelBrowser->request($method, $resolvedUrl, $options['extra']['parameters'] ?? [], $options['extra']['files'] ?? [], $server, $options['body'] ?? null); return $this->response = new Response($this->kernelBrowser->getResponse(), $this->kernelBrowser->getInternalResponse(), $info); } From 433a7730440a6cc7e1b862c8f5a98e7b4b0e6116 Mon Sep 17 00:00:00 2001 From: MartkCz Date: Thu, 16 Apr 2020 10:42:12 +0200 Subject: [PATCH 070/160] Fix accessing a non existent key --- .../Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index 6fc9f146da4..68323417c8a 100644 --- a/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -180,7 +180,7 @@ private function registerCommonConfiguration(ContainerBuilder $container, array $container->setParameter('api_platform.collection.pagination.enabled_parameter_name', $config['defaults']['pagination_enabled_parameter_name'] ?? $config['collection']['pagination']['enabled_parameter_name']); $container->setParameter('api_platform.collection.pagination.items_per_page_parameter_name', $config['defaults']['pagination_items_per_page_parameter_name'] ?? $config['collection']['pagination']['items_per_page_parameter_name']); $container->setParameter('api_platform.collection.pagination.partial_parameter_name', $config['defaults']['pagination_partial_parameter_name'] ?? $config['collection']['pagination']['partial_parameter_name']); - $container->setParameter('api_platform.collection.pagination', $this->getPaginationDefaults($config['defaults'], $config['collection']['pagination'])); + $container->setParameter('api_platform.collection.pagination', $this->getPaginationDefaults($config['defaults'] ?? [], $config['collection']['pagination'])); $container->setParameter('api_platform.http_cache.etag', $config['defaults']['cache_headers']['etag'] ?? $config['http_cache']['etag']); $container->setParameter('api_platform.http_cache.max_age', $config['defaults']['cache_headers']['max_age'] ?? $config['http_cache']['max_age']); $container->setParameter('api_platform.http_cache.shared_max_age', $config['defaults']['cache_headers']['shared_max_age'] ?? $config['http_cache']['shared_max_age']); From 03e5424fc4ef4e2e59837062e660db43be754f68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Dunglas?= Date: Mon, 20 Apr 2020 16:20:40 +0200 Subject: [PATCH 071/160] Fix CS --- src/Annotation/ApiProperty.php | 4 ++-- src/Filter/Validator/ValidatorInterface.php | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Annotation/ApiProperty.php b/src/Annotation/ApiProperty.php index 83b161e5ab3..ab8152746c5 100644 --- a/src/Annotation/ApiProperty.php +++ b/src/Annotation/ApiProperty.php @@ -77,12 +77,12 @@ final class ApiProperty public $identifier; /** - * @var mixed + * @var string|int|float|bool|array|null */ public $default; /** - * @var mixed + * @var string|int|float|bool|array|null */ public $example; diff --git a/src/Filter/Validator/ValidatorInterface.php b/src/Filter/Validator/ValidatorInterface.php index 2a2113362b0..f111137c8c3 100644 --- a/src/Filter/Validator/ValidatorInterface.php +++ b/src/Filter/Validator/ValidatorInterface.php @@ -16,9 +16,9 @@ interface ValidatorInterface { /** - * @var string the parameter name to validate - * @var array $filterDescription the filter descriptions as returned by `ApiPlatform\Core\Api\FilterInterface::getDescription()` - * @var array $queryParameters the list of query parameter + * @param string $name the parameter name to validate + * @param array $filterDescription the filter descriptions as returned by `ApiPlatform\Core\Api\FilterInterface::getDescription()` + * @param array $queryParameters the list of query parameter */ public function validate(string $name, array $filterDescription, array $queryParameters): array; } From 6e3a259eeaf6bab48a6c1ccd06afc872e798e6cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20H=C3=A9bert?= Date: Tue, 21 Apr 2020 11:33:31 +0200 Subject: [PATCH 072/160] Implement ApiProperty security attribute (#3503) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Implement ApiProperty security attribute * add tests * fix test Co-authored-by: Frédéric Barthelet --- features/authorization/deny.feature | 53 +++++++++- features/graphql/authorization.feature | 6 +- src/Annotation/ApiProperty.php | 8 ++ .../Symfony/Bundle/Resources/config/api.xml | 2 +- .../Symfony/Bundle/Resources/config/hal.xml | 2 +- .../Bundle/Resources/config/jsonapi.xml | 2 +- .../Bundle/Resources/config/jsonld.xml | 2 +- src/JsonApi/Serializer/ItemNormalizer.php | 5 +- src/JsonLd/Serializer/ItemNormalizer.php | 5 +- src/Serializer/AbstractItemNormalizer.php | 24 ++++- src/Serializer/ItemNormalizer.php | 5 +- tests/Annotation/ApiPropertyTest.php | 2 + .../TestBundle/Document/SecuredDummy.php | 21 +++- .../TestBundle/Entity/SecuredDummy.php | 21 +++- .../Serializer/AbstractItemNormalizerTest.php | 97 +++++++++++++++---- 15 files changed, 220 insertions(+), 35 deletions(-) diff --git a/features/authorization/deny.feature b/features/authorization/deny.feature index 404a8c45438..54d63181f1f 100644 --- a/features/authorization/deny.feature +++ b/features/authorization/deny.feature @@ -59,7 +59,8 @@ Feature: Authorization checking { "title": "Special Title", "description": "Description", - "owner": "dunglas" + "owner": "dunglas", + "adminOnlyProperty": "secret" } """ Then the response status code should be 201 @@ -100,3 +101,53 @@ Feature: Authorization checking } """ Then the response status code should be 200 + + Scenario: An admin retrieves a resource with an admin only viewable property + When I add "Accept" header equal to "application/ld+json" + And I add "Content-Type" header equal to "application/ld+json" + And I add "Authorization" header equal to "Basic YWRtaW46a2l0dGVu" + And I send a "GET" request to "/secured_dummies" + Then the response status code should be 200 + And the response should contain "adminOnlyProperty" + + Scenario: A user retrieves a resource with an admin only viewable property + When I add "Accept" header equal to "application/ld+json" + And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" + And I send a "GET" request to "/secured_dummies" + Then the response status code should be 200 + And the response should not contain "adminOnlyProperty" + + Scenario: An admin can create a secured resource with a secured Property + When I add "Accept" header equal to "application/ld+json" + And I add "Content-Type" header equal to "application/ld+json" + And I add "Authorization" header equal to "Basic YWRtaW46a2l0dGVu" + And I send a "POST" request to "/secured_dummies" with body: + """ + { + "title": "Common Title", + "description": "Description", + "owner": "dunglas", + "adminOnlyProperty": "Is it safe?" + } + """ + Then the response status code should be 201 + And the response should contain "adminOnlyProperty" + And the JSON node "adminOnlyProperty" should be equal to the string "Is it safe?" + + Scenario: A user cannot update a secured property + When I add "Accept" header equal to "application/ld+json" + And I add "Content-Type" header equal to "application/ld+json" + And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" + And I send a "PUT" request to "/secured_dummies/3" with body: + """ + { + "adminOnlyProperty": "Yes it is!" + } + """ + Then the response status code should be 200 + And the response should not contain "adminOnlyProperty" + And I add "Authorization" header equal to "Basic YWRtaW46a2l0dGVu" + And I send a "GET" request to "/secured_dummies" + Then the response status code should be 200 + And the response should contain "adminOnlyProperty" + And the JSON node "hydra:member[2].adminOnlyProperty" should be equal to the string "Is it safe?" diff --git a/features/graphql/authorization.feature b/features/graphql/authorization.feature index a4cc77c4ccd..165b0131c20 100644 --- a/features/graphql/authorization.feature +++ b/features/graphql/authorization.feature @@ -91,7 +91,7 @@ Feature: Authorization checking When I send the following GraphQL request: """ mutation { - createSecuredDummy(input: {owner: "me", title: "Hi", description: "Desc", clientMutationId: "auth"}) { + createSecuredDummy(input: {owner: "me", title: "Hi", description: "Desc", adminOnlyProperty: "secret", clientMutationId: "auth"}) { securedDummy { title owner @@ -112,7 +112,7 @@ Feature: Authorization checking And I send the following GraphQL request: """ mutation { - createSecuredDummy(input: {owner: "someone", title: "Hi", description: "Desc"}) { + createSecuredDummy(input: {owner: "someone", title: "Hi", description: "Desc", adminOnlyProperty: "secret"}) { securedDummy { id title @@ -131,7 +131,7 @@ Feature: Authorization checking And I send the following GraphQL request: """ mutation { - createSecuredDummy(input: {owner: "dunglas", title: "Hi", description: "Desc"}) { + createSecuredDummy(input: {owner: "dunglas", title: "Hi", description: "Desc", adminOnlyProperty: "secret"}) { securedDummy { id title diff --git a/src/Annotation/ApiProperty.php b/src/Annotation/ApiProperty.php index ab8152746c5..7fcc96cabe0 100644 --- a/src/Annotation/ApiProperty.php +++ b/src/Annotation/ApiProperty.php @@ -29,6 +29,7 @@ * @Attribute("openapiContext", type="array"), * @Attribute("jsonldContext", type="array"), * @Attribute("push", type="bool"), + * @Attribute("security", type="string"), * @Attribute("swaggerContext", type="array") * ) */ @@ -128,6 +129,13 @@ final class ApiProperty */ private $push; + /** + * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 + * + * @var string + */ + private $security; + /** * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 * diff --git a/src/Bridge/Symfony/Bundle/Resources/config/api.xml b/src/Bridge/Symfony/Bundle/Resources/config/api.xml index dbc7cf741e5..f475bf67a36 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/api.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/api.xml @@ -115,7 +115,7 @@ null - false + diff --git a/src/Bridge/Symfony/Bundle/Resources/config/hal.xml b/src/Bridge/Symfony/Bundle/Resources/config/hal.xml index 2828d13eec5..a42c976c90b 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/hal.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/hal.xml @@ -40,7 +40,7 @@ - false + diff --git a/src/Bridge/Symfony/Bundle/Resources/config/jsonapi.xml b/src/Bridge/Symfony/Bundle/Resources/config/jsonapi.xml index 410a327b43d..7d232706bc1 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/jsonapi.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/jsonapi.xml @@ -41,7 +41,7 @@ - false + diff --git a/src/Bridge/Symfony/Bundle/Resources/config/jsonld.xml b/src/Bridge/Symfony/Bundle/Resources/config/jsonld.xml index b40b0ed1c2d..3912ad1e1b4 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/jsonld.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/jsonld.xml @@ -27,7 +27,7 @@ - false + diff --git a/src/JsonApi/Serializer/ItemNormalizer.php b/src/JsonApi/Serializer/ItemNormalizer.php index ca869d67f0b..ade96fe723c 100644 --- a/src/JsonApi/Serializer/ItemNormalizer.php +++ b/src/JsonApi/Serializer/ItemNormalizer.php @@ -20,6 +20,7 @@ use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Core\Metadata\Property\PropertyMetadata; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\Security\ResourceAccessCheckerInterface; use ApiPlatform\Core\Serializer\AbstractItemNormalizer; use ApiPlatform\Core\Serializer\CacheKeyTrait; use ApiPlatform\Core\Serializer\ContextTrait; @@ -49,9 +50,9 @@ final class ItemNormalizer extends AbstractItemNormalizer private $componentsCache = []; - public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, ?PropertyAccessorInterface $propertyAccessor, ?NameConverterInterface $nameConverter, ResourceMetadataFactoryInterface $resourceMetadataFactory, array $defaultContext = [], iterable $dataTransformers = []) + public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, ?PropertyAccessorInterface $propertyAccessor, ?NameConverterInterface $nameConverter, ResourceMetadataFactoryInterface $resourceMetadataFactory, array $defaultContext = [], iterable $dataTransformers = [], ResourceAccessCheckerInterface $resourceAccessChecker = null) { - parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, null, null, false, $defaultContext, $dataTransformers, $resourceMetadataFactory); + parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, null, null, false, $defaultContext, $dataTransformers, $resourceMetadataFactory, $resourceAccessChecker); } /** diff --git a/src/JsonLd/Serializer/ItemNormalizer.php b/src/JsonLd/Serializer/ItemNormalizer.php index aca10e346d7..197050fdd55 100644 --- a/src/JsonLd/Serializer/ItemNormalizer.php +++ b/src/JsonLd/Serializer/ItemNormalizer.php @@ -19,6 +19,7 @@ use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\Security\ResourceAccessCheckerInterface; use ApiPlatform\Core\Serializer\AbstractItemNormalizer; use ApiPlatform\Core\Serializer\ContextTrait; use ApiPlatform\Core\Util\ClassInfoTrait; @@ -43,9 +44,9 @@ final class ItemNormalizer extends AbstractItemNormalizer private $contextBuilder; - public function __construct(ResourceMetadataFactoryInterface $resourceMetadataFactory, PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, ContextBuilderInterface $contextBuilder, PropertyAccessorInterface $propertyAccessor = null, NameConverterInterface $nameConverter = null, ClassMetadataFactoryInterface $classMetadataFactory = null, array $defaultContext = [], iterable $dataTransformers = []) + public function __construct(ResourceMetadataFactoryInterface $resourceMetadataFactory, PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, ContextBuilderInterface $contextBuilder, PropertyAccessorInterface $propertyAccessor = null, NameConverterInterface $nameConverter = null, ClassMetadataFactoryInterface $classMetadataFactory = null, array $defaultContext = [], iterable $dataTransformers = [], ResourceAccessCheckerInterface $resourceAccessChecker = null) { - parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, null, false, $defaultContext, $dataTransformers, $resourceMetadataFactory); + parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, null, false, $defaultContext, $dataTransformers, $resourceMetadataFactory, $resourceAccessChecker); $this->contextBuilder = $contextBuilder; } diff --git a/src/Serializer/AbstractItemNormalizer.php b/src/Serializer/AbstractItemNormalizer.php index d56393e5da8..3b4d4446bbe 100644 --- a/src/Serializer/AbstractItemNormalizer.php +++ b/src/Serializer/AbstractItemNormalizer.php @@ -24,6 +24,7 @@ use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Core\Metadata\Property\PropertyMetadata; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\Security\ResourceAccessCheckerInterface; use ApiPlatform\Core\Util\ClassInfoTrait; use Symfony\Component\PropertyAccess\Exception\NoSuchPropertyException; use Symfony\Component\PropertyAccess\PropertyAccess; @@ -55,13 +56,14 @@ abstract class AbstractItemNormalizer extends AbstractObjectNormalizer protected $propertyMetadataFactory; protected $iriConverter; protected $resourceClassResolver; + protected $resourceAccessChecker; protected $propertyAccessor; protected $itemDataProvider; protected $allowPlainIdentifiers; protected $dataTransformers = []; protected $localCache = []; - public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, PropertyAccessorInterface $propertyAccessor = null, NameConverterInterface $nameConverter = null, ClassMetadataFactoryInterface $classMetadataFactory = null, ItemDataProviderInterface $itemDataProvider = null, bool $allowPlainIdentifiers = false, array $defaultContext = [], iterable $dataTransformers = [], ResourceMetadataFactoryInterface $resourceMetadataFactory = null) + public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, PropertyAccessorInterface $propertyAccessor = null, NameConverterInterface $nameConverter = null, ClassMetadataFactoryInterface $classMetadataFactory = null, ItemDataProviderInterface $itemDataProvider = null, bool $allowPlainIdentifiers = false, array $defaultContext = [], iterable $dataTransformers = [], ResourceMetadataFactoryInterface $resourceMetadataFactory = null, ResourceAccessCheckerInterface $resourceAccessChecker = null) { if (!isset($defaultContext['circular_reference_handler'])) { $defaultContext['circular_reference_handler'] = function ($object) { @@ -83,6 +85,7 @@ public function __construct(PropertyNameCollectionFactoryInterface $propertyName $this->allowPlainIdentifiers = $allowPlainIdentifiers; $this->dataTransformers = $dataTransformers; $this->resourceMetadataFactory = $resourceMetadataFactory; + $this->resourceAccessChecker = $resourceAccessChecker; } /** @@ -349,6 +352,25 @@ protected function getAllowedAttributes($classOrObject, array $context, $attribu return $allowedAttributes; } + /** + * {@inheritdoc} + */ + protected function isAllowedAttribute($classOrObject, $attribute, $format = null, array $context = []) + { + if (!parent::isAllowedAttribute($classOrObject, $attribute, $format, $context)) { + return false; + } + + $options = $this->getFactoryOptions($context); + $propertyMetadata = $this->propertyMetadataFactory->create($context['resource_class'], $attribute, $options); + $security = $propertyMetadata->getAttribute('security'); + if ($this->resourceAccessChecker && $security) { + return $this->resourceAccessChecker->isGranted($attribute, $security); + } + + return true; + } + /** * {@inheritdoc} */ diff --git a/src/Serializer/ItemNormalizer.php b/src/Serializer/ItemNormalizer.php index f593dafddbb..293b7955766 100644 --- a/src/Serializer/ItemNormalizer.php +++ b/src/Serializer/ItemNormalizer.php @@ -20,6 +20,7 @@ use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\Security\ResourceAccessCheckerInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; @@ -36,9 +37,9 @@ class ItemNormalizer extends AbstractItemNormalizer { private $logger; - public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, PropertyAccessorInterface $propertyAccessor = null, NameConverterInterface $nameConverter = null, ClassMetadataFactoryInterface $classMetadataFactory = null, ItemDataProviderInterface $itemDataProvider = null, bool $allowPlainIdentifiers = false, LoggerInterface $logger = null, iterable $dataTransformers = [], ResourceMetadataFactoryInterface $resourceMetadataFactory = null) + public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, PropertyAccessorInterface $propertyAccessor = null, NameConverterInterface $nameConverter = null, ClassMetadataFactoryInterface $classMetadataFactory = null, ItemDataProviderInterface $itemDataProvider = null, bool $allowPlainIdentifiers = false, LoggerInterface $logger = null, iterable $dataTransformers = [], ResourceMetadataFactoryInterface $resourceMetadataFactory = null, ResourceAccessCheckerInterface $resourceAccessChecker = null) { - parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $itemDataProvider, $allowPlainIdentifiers, [], $dataTransformers, $resourceMetadataFactory); + parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $itemDataProvider, $allowPlainIdentifiers, [], $dataTransformers, $resourceMetadataFactory, $resourceAccessChecker); $this->logger = $logger ?: new NullLogger(); } diff --git a/tests/Annotation/ApiPropertyTest.php b/tests/Annotation/ApiPropertyTest.php index ea08ce657d0..aa550d42e4f 100644 --- a/tests/Annotation/ApiPropertyTest.php +++ b/tests/Annotation/ApiPropertyTest.php @@ -52,6 +52,7 @@ public function testConstruct() 'fetchable' => true, 'fetchEager' => false, 'jsonldContext' => ['foo' => 'bar'], + 'security' => 'is_granted(\'ROLE_ADMIN\')', 'swaggerContext' => ['foo' => 'baz'], 'openapiContext' => ['foo' => 'baz'], 'push' => true, @@ -62,6 +63,7 @@ public function testConstruct() 'fetchable' => false, 'fetch_eager' => false, 'jsonld_context' => ['foo' => 'bar'], + 'security' => 'is_granted(\'ROLE_ADMIN\')', 'swagger_context' => ['foo' => 'baz'], 'openapi_context' => ['foo' => 'baz'], 'push' => true, diff --git a/tests/Fixtures/TestBundle/Document/SecuredDummy.php b/tests/Fixtures/TestBundle/Document/SecuredDummy.php index bc932469381..6820d13b0f5 100644 --- a/tests/Fixtures/TestBundle/Document/SecuredDummy.php +++ b/tests/Fixtures/TestBundle/Document/SecuredDummy.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Document; +use ApiPlatform\Core\Annotation\ApiProperty; use ApiPlatform\Core\Annotation\ApiResource; use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; use Symfony\Component\Validator\Constraints as Assert; @@ -26,7 +27,7 @@ * @ApiResource( * attributes={"security"="is_granted('ROLE_USER')"}, * collectionOperations={ - * "get", + * "get"={"security"="is_granted('ROLE_USER') or is_granted('ROLE_ADMIN')"}, * "get_from_data_provider_generator"={ * "method"="GET", * "path"="custom_data_provider_generator", @@ -72,6 +73,14 @@ class SecuredDummy */ private $description = ''; + /** + * @var string The dummy secret property, only readable/writable by specific users + * + * @ODM\Field + * @ApiProperty(security="is_granted('ROLE_ADMIN')") + */ + private $adminOnlyProperty = ''; + /** * @var string The owner * @@ -105,6 +114,16 @@ public function setDescription(string $description) $this->description = $description; } + public function getAdminOnlyProperty(): ?string + { + return $this->adminOnlyProperty; + } + + public function setAdminOnlyProperty(?string $adminOnlyProperty) + { + $this->adminOnlyProperty = $adminOnlyProperty; + } + public function getOwner(): string { return $this->owner; diff --git a/tests/Fixtures/TestBundle/Entity/SecuredDummy.php b/tests/Fixtures/TestBundle/Entity/SecuredDummy.php index f01d909c33c..19ffc2cc816 100644 --- a/tests/Fixtures/TestBundle/Entity/SecuredDummy.php +++ b/tests/Fixtures/TestBundle/Entity/SecuredDummy.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity; +use ApiPlatform\Core\Annotation\ApiProperty; use ApiPlatform\Core\Annotation\ApiResource; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Validator\Constraints as Assert; @@ -25,7 +26,7 @@ * @ApiResource( * attributes={"security"="is_granted('ROLE_USER')"}, * collectionOperations={ - * "get", + * "get"={"security"="is_granted('ROLE_USER') or is_granted('ROLE_ADMIN')"}, * "get_from_data_provider_generator"={ * "method"="GET", * "path"="custom_data_provider_generator", @@ -73,6 +74,14 @@ class SecuredDummy */ private $description = ''; + /** + * @var string The dummy secret property, only readable/writable by specific users + * + * @ORM\Column + * @ApiProperty(security="is_granted('ROLE_ADMIN')") + */ + private $adminOnlyProperty = ''; + /** * @var string The owner * @@ -106,6 +115,16 @@ public function setDescription(string $description) $this->description = $description; } + public function getAdminOnlyProperty(): ?string + { + return $this->adminOnlyProperty; + } + + public function setAdminOnlyProperty(?string $adminOnlyProperty) + { + $this->adminOnlyProperty = $adminOnlyProperty; + } + public function getOwner(): string { return $this->owner; diff --git a/tests/Serializer/AbstractItemNormalizerTest.php b/tests/Serializer/AbstractItemNormalizerTest.php index a1b9fead23a..c78fe49d388 100644 --- a/tests/Serializer/AbstractItemNormalizerTest.php +++ b/tests/Serializer/AbstractItemNormalizerTest.php @@ -25,6 +25,7 @@ use ApiPlatform\Core\Metadata\Property\PropertyNameCollection; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; +use ApiPlatform\Core\Security\ResourceAccessCheckerInterface; use ApiPlatform\Core\Serializer\AbstractItemNormalizer; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Dto\InputDto; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy; @@ -33,6 +34,7 @@ use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyTableInheritance; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyTableInheritanceChild; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\RelatedDummy; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\SecuredDummy; use Doctrine\Common\Collections\ArrayCollection; use PHPUnit\Framework\TestCase; use Prophecy\Argument; @@ -110,7 +112,7 @@ public function testSupportNormalizationAndSupportDenormalization() [], [], null, - false, + null, ]); $this->assertTrue($normalizer->supportsNormalization($dummy)); @@ -177,7 +179,7 @@ public function testNormalize() [], [], null, - false, + null, ]); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -196,6 +198,65 @@ public function testNormalize() ])); } + public function testNormalizeWithSecuredProperty() + { + $dummy = new SecuredDummy(); + $dummy->setTitle('myPublicTitle'); + $dummy->setAdminOnlyProperty('secret'); + + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(SecuredDummy::class, [])->willReturn(new PropertyNameCollection(['title', 'adminOnlyProperty'])); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'title', [])->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING), '', true)); + $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'adminOnlyProperty', [])->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING), '', true, null, null, null, null, null, null, null, ['security' => 'is_granted(\'ROLE_ADMIN\')'])); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getIriFromItem($dummy)->willReturn('/secured_dummies/1'); + + $propertyAccessorProphecy = $this->prophesize(PropertyAccessorInterface::class); + $propertyAccessorProphecy->getValue($dummy, 'title')->willReturn('myPublicTitle'); + $propertyAccessorProphecy->getValue($dummy, 'adminOnlyProperty')->willReturn('secret'); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass($dummy, null)->willReturn(SecuredDummy::class); + + $resourceAccessChecker = $this->prophesize(ResourceAccessCheckerInterface::class); + $resourceAccessChecker->isGranted('adminOnlyProperty', 'is_granted(\'ROLE_ADMIN\')')->willReturn(false); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->willImplement(NormalizerInterface::class); + $serializerProphecy->normalize('myPublicTitle', null, Argument::type('array'))->willReturn('myPublicTitle'); + + $normalizer = $this->getMockForAbstractClass(AbstractItemNormalizer::class, [ + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal(), + $propertyAccessorProphecy->reveal(), + null, + null, + null, + false, + [], + [], + null, + $resourceAccessChecker->reveal(), + ]); + $normalizer->setSerializer($serializerProphecy->reveal()); + + if (!interface_exists(AdvancedNameConverterInterface::class) && method_exists($normalizer, 'setIgnoredAttributes')) { + $normalizer->setIgnoredAttributes(['alias']); + } + + $expected = [ + 'title' => 'myPublicTitle', + ]; + $this->assertEquals($expected, $normalizer->normalize($dummy, null, [ + 'resources' => [], + ])); + } + public function testNormalizeReadableLinks() { $relatedDummy = new RelatedDummy(); @@ -253,7 +314,7 @@ public function testNormalizeReadableLinks() [], [], null, - false, + null, ]); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -315,7 +376,7 @@ public function testDenormalize() [], [], null, - false, + null, ]); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -365,7 +426,7 @@ public function testCanDenormalizeInputClassWithDifferentFieldsThanResourceClass $serializerProphecy->willImplement(DenormalizerInterface::class); $serializerProphecy->denormalize($data, DummyForAdditionalFieldsInput::class, 'json', $cleanedContext)->willReturn($dummyInputDto); - $normalizer = new class($propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), $iriConverterProphecy->reveal(), $resourceClassResolverProphecy->reveal(), null, null, null, null, false, [], [$inputDataTransformerProphecy->reveal()], null) extends AbstractItemNormalizer { + $normalizer = new class($propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), $iriConverterProphecy->reveal(), $resourceClassResolverProphecy->reveal(), null, null, null, null, false, [], [$inputDataTransformerProphecy->reveal()], null, null) extends AbstractItemNormalizer { }; $normalizer->setSerializer($serializerProphecy->reveal()); @@ -424,7 +485,7 @@ public function testDenormalizeWritableLinks() [], [], null, - false, + null, ]); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -486,7 +547,7 @@ public function testBadRelationType() [], [], null, - false, + null, ]); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -544,7 +605,7 @@ public function testInnerDocumentNotAllowed() [], [], null, - false, + null, ]); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -589,7 +650,7 @@ public function testBadType() [], [], null, - false, + null, ]); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -631,7 +692,7 @@ public function testTypeChecksCanBeDisabled() [], [], null, - false, + null, ]); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -677,7 +738,7 @@ public function testJsonAllowIntAsFloat() [], [], null, - false, + null, ]); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -752,7 +813,7 @@ public function testDenormalizeBadKeyType() [], [], null, - false, + null, ]); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -794,7 +855,7 @@ public function testNullable() [], [], null, - false, + null, ]); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -854,7 +915,7 @@ public function testChildInheritedProperty(): void [], [], null, - false, + null, ]); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -906,7 +967,7 @@ public function testDenormalizeRelationWithPlainId() [], [], null, - false, + null, ]); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -969,7 +1030,7 @@ public function testDenormalizeRelationWithPlainIdNotFound() [], [], null, - true, + null, ]); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -1027,7 +1088,7 @@ public function testDoNotDenormalizeRelationWithPlainIdWhenPlainIdentifiersAreNo [], [], null, - false, + null, ]); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -1114,7 +1175,7 @@ public function testNormalizationWithDataTransformer() [], [$dataTransformerProphecy->reveal(), $secondDataTransformerProphecy->reveal()], $resourceMetadataFactoryProphecy->reveal(), - false, + null, ]); $normalizer->setSerializer($serializerProphecy->reveal()); From a880d96a9e7828e4019d83276e17ddc862da255f Mon Sep 17 00:00:00 2001 From: MartkCz Date: Tue, 21 Apr 2020 11:36:41 +0200 Subject: [PATCH 073/160] [GraphQL] add support for Symfony Serializer's `@SerializedName` metadata (#3455) * [GraphQL] add support for Symfony Serializer's `@SerializedName` metadata * SerializedName used in message in ValidationExceptionNormalizer * Revert "SerializedName used in message in ValidationExceptionNormalizer" This reverts commit 51bc4ba5c66ca97cf15d78613daae5cd89422f3a. --- src/GraphQl/Type/FieldsBuilder.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/GraphQl/Type/FieldsBuilder.php b/src/GraphQl/Type/FieldsBuilder.php index 3842f00ae4d..ef8655c568e 100644 --- a/src/GraphQl/Type/FieldsBuilder.php +++ b/src/GraphQl/Type/FieldsBuilder.php @@ -224,7 +224,7 @@ public function getResourceObjectTypeFields(?string $resourceClass, ResourceMeta } if ($fieldConfiguration = $this->getResourceFieldConfiguration($property, $propertyMetadata->getDescription(), $propertyMetadata->getAttribute('deprecation_reason', ''), $propertyType, $resourceClass, $input, $queryName, $mutationName, $subscriptionName, $depth)) { - $fields['id' === $property ? '_id' : $this->normalizePropertyName($property)] = $fieldConfiguration; + $fields['id' === $property ? '_id' : $this->normalizePropertyName($property, $resourceClass)] = $fieldConfiguration; } } } @@ -493,8 +493,8 @@ private function convertType(Type $type, bool $input, ?string $queryName, ?strin : GraphQLType::nonNull($graphqlType); } - private function normalizePropertyName(string $property): string + private function normalizePropertyName(string $property, string $resourceClass): string { - return null !== $this->nameConverter ? $this->nameConverter->normalize($property) : $property; + return null !== $this->nameConverter ? $this->nameConverter->normalize($property, $resourceClass) : $property; } } From 887d7077910d7ad6006709db14b03ac83666b9f5 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Tue, 21 Apr 2020 11:49:01 +0200 Subject: [PATCH 074/160] feat: CookieJar on Client (#3418) * feat: CookieJar on Client * typo: phpdoc @return for cookieJar --- src/Bridge/Symfony/Bundle/Test/Client.php | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/Bridge/Symfony/Bundle/Test/Client.php b/src/Bridge/Symfony/Bundle/Test/Client.php index 270d560c8e0..bf6ec3df815 100644 --- a/src/Bridge/Symfony/Bundle/Test/Client.php +++ b/src/Bridge/Symfony/Bundle/Test/Client.php @@ -14,6 +14,7 @@ namespace ApiPlatform\Core\Bridge\Symfony\Bundle\Test; use Symfony\Bundle\FrameworkBundle\KernelBrowser; +use Symfony\Component\BrowserKit\CookieJar; use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\HttpClient\HttpClientTrait; use Symfony\Component\HttpKernel\KernelInterface; @@ -167,6 +168,14 @@ public function getContainer(): ?ContainerInterface return $this->kernelBrowser->getContainer(); } + /** + * Returns the CookieJar instance. + */ + public function getCookieJar(): CookieJar + { + return $this->kernelBrowser->getCookieJar(); + } + /** * Returns the kernel. */ From 8fde8f18863d6ac57b9aad6d906e589e5df0a85c Mon Sep 17 00:00:00 2001 From: MartkCz Date: Wed, 22 Apr 2020 10:51:51 +0200 Subject: [PATCH 075/160] [GraphQL] Mutations have description field (#3477) --- src/GraphQl/Type/FieldsBuilder.php | 6 ++-- tests/GraphQl/Type/FieldsBuilderTest.php | 40 ++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/src/GraphQl/Type/FieldsBuilder.php b/src/GraphQl/Type/FieldsBuilder.php index ef8655c568e..e5f08746cd8 100644 --- a/src/GraphQl/Type/FieldsBuilder.php +++ b/src/GraphQl/Type/FieldsBuilder.php @@ -135,9 +135,10 @@ public function getMutationFields(string $resourceClass, ResourceMetadata $resou $mutationFields = []; $shortName = $resourceMetadata->getShortName(); $resourceType = new Type(Type::BUILTIN_TYPE_OBJECT, true, $resourceClass); + $description = $resourceMetadata->getGraphqlAttribute($mutationName, 'description', ucfirst("{$mutationName}s a $shortName."), false); $deprecationReason = $resourceMetadata->getGraphqlAttribute($mutationName, 'deprecation_reason', '', true); - if ($fieldConfiguration = $this->getResourceFieldConfiguration(null, ucfirst("{$mutationName}s a $shortName."), $deprecationReason, $resourceType, $resourceClass, false, null, $mutationName, null)) { + if ($fieldConfiguration = $this->getResourceFieldConfiguration(null, $description, $deprecationReason, $resourceType, $resourceClass, false, null, $mutationName, null)) { $fieldConfiguration['args'] += ['input' => $this->getResourceFieldConfiguration(null, null, $deprecationReason, $resourceType, $resourceClass, true, null, $mutationName, null)]; } @@ -154,9 +155,10 @@ public function getSubscriptionFields(string $resourceClass, ResourceMetadata $r $subscriptionFields = []; $shortName = $resourceMetadata->getShortName(); $resourceType = new Type(Type::BUILTIN_TYPE_OBJECT, true, $resourceClass); + $description = $resourceMetadata->getGraphqlAttribute($subscriptionName, 'description', "Subscribes to the $subscriptionName event of a $shortName.", false); $deprecationReason = $resourceMetadata->getGraphqlAttribute($subscriptionName, 'deprecation_reason', '', true); - if ($fieldConfiguration = $this->getResourceFieldConfiguration(null, "Subscribes to the $subscriptionName event of a $shortName.", $deprecationReason, $resourceType, $resourceClass, false, null, null, $subscriptionName)) { + if ($fieldConfiguration = $this->getResourceFieldConfiguration(null, $description, $deprecationReason, $resourceType, $resourceClass, false, null, null, $subscriptionName)) { $fieldConfiguration['args'] += ['input' => $this->getResourceFieldConfiguration(null, null, $deprecationReason, $resourceType, $resourceClass, true, null, null, $subscriptionName)]; } diff --git a/tests/GraphQl/Type/FieldsBuilderTest.php b/tests/GraphQl/Type/FieldsBuilderTest.php index eceba93f963..0fd6086e065 100644 --- a/tests/GraphQl/Type/FieldsBuilderTest.php +++ b/tests/GraphQl/Type/FieldsBuilderTest.php @@ -393,6 +393,26 @@ public function mutationFieldsProvider(): array ], ], ], + 'custom description' => ['resourceClass', (new ResourceMetadata('ShortName'))->withGraphql(['action' => ['description' => 'Custom description.']]), 'action', $graphqlType = new ObjectType(['name' => 'mutation']), $inputGraphqlType = new ObjectType(['name' => 'input']), $mutationResolver = function () { + }, + [ + 'actionShortName' => [ + 'type' => $graphqlType, + 'description' => 'Custom description.', + 'args' => [ + 'input' => [ + 'type' => $inputGraphqlType, + 'description' => null, + 'args' => [], + 'resolve' => null, + 'deprecationReason' => '', + ], + ], + 'resolve' => $mutationResolver, + 'deprecationReason' => '', + ], + ], + ], ]; } @@ -438,6 +458,26 @@ public function subscriptionFieldsProvider(): array ], ], ], + 'custom description' => ['resourceClass', (new ResourceMetadata('ShortName'))->withAttributes(['mercure' => true])->withGraphql(['action' => ['description' => 'Custom description.']]), 'action', $graphqlType = new ObjectType(['name' => 'subscription']), $inputGraphqlType = new ObjectType(['name' => 'input']), $subscriptionResolver = function () { + }, + [ + 'actionShortNameSubscribe' => [ + 'type' => $graphqlType, + 'description' => 'Custom description.', + 'args' => [ + 'input' => [ + 'type' => $inputGraphqlType, + 'description' => null, + 'args' => [], + 'resolve' => null, + 'deprecationReason' => '', + ], + ], + 'resolve' => $subscriptionResolver, + 'deprecationReason' => '', + ], + ], + ], ]; } From 7e69666f2834a7d077ac0bbbe4de647f2592cfab Mon Sep 17 00:00:00 2001 From: Alan Poulain Date: Wed, 22 Apr 2020 12:28:31 +0200 Subject: [PATCH 076/160] [GraphQL] Custom description for queries (#3514) --- CHANGELOG.md | 1 + src/GraphQl/Type/FieldsBuilder.php | 8 ++++---- tests/GraphQl/Type/FieldsBuilderTest.php | 8 ++++---- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6448fed34e6..df11bfe125a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ * GraphQL: Subscription support with Mercure (#3321) * GraphQL: Allow to format GraphQL errors based on exceptions (#3063) * GraphQL: Add page-based pagination (#3175) +* GraphQL: Possibility to add a custom description for queries, mutations and subscriptions (#3477, #3514) * OpenAPI: Add PHP default values to the documentation (#2386) * Deprecate using a validation groups generator service not implementing `ApiPlatform\Core\Bridge\Symfony\Validator\ValidationGroupsGeneratorInterface` (#3346) diff --git a/src/GraphQl/Type/FieldsBuilder.php b/src/GraphQl/Type/FieldsBuilder.php index e5f08746cd8..aef61d96449 100644 --- a/src/GraphQl/Type/FieldsBuilder.php +++ b/src/GraphQl/Type/FieldsBuilder.php @@ -94,10 +94,10 @@ public function getItemQueryFields(string $resourceClass, ResourceMetadata $reso { $shortName = $resourceMetadata->getShortName(); $fieldName = lcfirst('item_query' === $queryName ? $shortName : $queryName.$shortName); - + $description = $resourceMetadata->getGraphqlAttribute($queryName, 'description'); $deprecationReason = (string) $resourceMetadata->getGraphqlAttribute($queryName, 'deprecation_reason', '', true); - if ($fieldConfiguration = $this->getResourceFieldConfiguration(null, null, $deprecationReason, new Type(Type::BUILTIN_TYPE_OBJECT, true, $resourceClass), $resourceClass, false, $queryName, null, null)) { + if ($fieldConfiguration = $this->getResourceFieldConfiguration(null, $description, $deprecationReason, new Type(Type::BUILTIN_TYPE_OBJECT, true, $resourceClass), $resourceClass, false, $queryName, null, null)) { $args = $this->resolveResourceArgs($configuration['args'] ?? [], $queryName, $shortName); $configuration['args'] = $args ?: $configuration['args'] ?? ['id' => ['type' => GraphQLType::nonNull(GraphQLType::id())]]; @@ -114,10 +114,10 @@ public function getCollectionQueryFields(string $resourceClass, ResourceMetadata { $shortName = $resourceMetadata->getShortName(); $fieldName = lcfirst('collection_query' === $queryName ? $shortName : $queryName.$shortName); - + $description = $resourceMetadata->getGraphqlAttribute($queryName, 'description'); $deprecationReason = (string) $resourceMetadata->getGraphqlAttribute($queryName, 'deprecation_reason', '', true); - if ($fieldConfiguration = $this->getResourceFieldConfiguration(null, null, $deprecationReason, new Type(Type::BUILTIN_TYPE_OBJECT, false, null, true, null, new Type(Type::BUILTIN_TYPE_OBJECT, false, $resourceClass)), $resourceClass, false, $queryName, null, null)) { + if ($fieldConfiguration = $this->getResourceFieldConfiguration(null, $description, $deprecationReason, new Type(Type::BUILTIN_TYPE_OBJECT, false, null, true, null, new Type(Type::BUILTIN_TYPE_OBJECT, false, $resourceClass)), $resourceClass, false, $queryName, null, null)) { $args = $this->resolveResourceArgs($configuration['args'] ?? [], $queryName, $shortName); $configuration['args'] = $args ?: $configuration['args'] ?? $fieldConfiguration['args']; diff --git a/tests/GraphQl/Type/FieldsBuilderTest.php b/tests/GraphQl/Type/FieldsBuilderTest.php index 0fd6086e065..dc95d41a093 100644 --- a/tests/GraphQl/Type/FieldsBuilderTest.php +++ b/tests/GraphQl/Type/FieldsBuilderTest.php @@ -143,11 +143,11 @@ public function itemQueryFieldsProvider(): array { return [ 'no resource field configuration' => ['resourceClass', new ResourceMetadata(), 'action', [], null, null, []], - 'nominal standard type case with deprecation reason' => ['resourceClass', (new ResourceMetadata('ShortName'))->withGraphql(['action' => ['deprecation_reason' => 'not useful']]), 'action', [], GraphQLType::string(), null, + 'nominal standard type case with deprecation reason and description' => ['resourceClass', (new ResourceMetadata('ShortName'))->withGraphql(['action' => ['deprecation_reason' => 'not useful', 'description' => 'Custom description.']]), 'action', [], GraphQLType::string(), null, [ 'actionShortName' => [ 'type' => GraphQLType::string(), - 'description' => null, + 'description' => 'Custom description.', 'args' => [ 'id' => ['type' => GraphQLType::nonNull(GraphQLType::id())], ], @@ -236,12 +236,12 @@ public function collectionQueryFieldsProvider(): array { return [ 'no resource field configuration' => ['resourceClass', new ResourceMetadata(), 'action', [], null, null, []], - 'nominal collection case with deprecation reason' => ['resourceClass', (new ResourceMetadata('ShortName'))->withGraphql(['action' => ['deprecation_reason' => 'not useful']]), 'action', [], $graphqlType = GraphQLType::listOf(new ObjectType(['name' => 'collection'])), $resolver = function () { + 'nominal collection case with deprecation reason and description' => ['resourceClass', (new ResourceMetadata('ShortName'))->withGraphql(['action' => ['deprecation_reason' => 'not useful', 'description' => 'Custom description.']]), 'action', [], $graphqlType = GraphQLType::listOf(new ObjectType(['name' => 'collection'])), $resolver = function () { }, [ 'actionShortNames' => [ 'type' => $graphqlType, - 'description' => null, + 'description' => 'Custom description.', 'args' => [ 'first' => [ 'type' => GraphQLType::int(), From 8d7290eb9150b3fdc0fb2ef36051a178eb18cdea Mon Sep 17 00:00:00 2001 From: Alan Poulain Date: Wed, 22 Apr 2020 17:23:06 +0200 Subject: [PATCH 077/160] [GraphQL] Support serialized name (#3516) --- CHANGELOG.md | 1 + features/doctrine/search_filter.feature | 3 +- features/graphql/query.feature | 12 ++++ .../Serializer/SerializerContextBuilder.php | 36 ++++++----- src/GraphQl/Type/FieldsBuilder.php | 10 +++- .../Fixtures/TestBundle/Document/DummyCar.php | 20 +++++++ tests/Fixtures/TestBundle/Entity/DummyCar.php | 20 +++++++ .../SerializerContextBuilderTest.php | 59 +++++++++++++++---- tests/GraphQl/Type/FieldsBuilderTest.php | 38 +++++++++++- .../AnnotationFilterExtractorTraitTest.php | 2 +- 10 files changed, 168 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index df11bfe125a..9a864b0fea9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ * GraphQL: Allow to format GraphQL errors based on exceptions (#3063) * GraphQL: Add page-based pagination (#3175) * GraphQL: Possibility to add a custom description for queries, mutations and subscriptions (#3477, #3514) +* GraphQL: Support for field name conversion (serialized name) (#3455, #3516) * OpenAPI: Add PHP default values to the documentation (#2386) * Deprecate using a validation groups generator service not implementing `ApiPlatform\Core\Bridge\Symfony\Validator\ValidationGroupsGeneratorInterface` (#3346) diff --git a/features/doctrine/search_filter.feature b/features/doctrine/search_filter.feature index 857e0c70718..e33b7515c47 100644 --- a/features/doctrine/search_filter.feature +++ b/features/doctrine/search_filter.feature @@ -65,7 +65,8 @@ Feature: Search filter on collections "prop": "blue" } ], - "uuid": [] + "uuid": [], + "carBrand": "DummyBrand" } ], "hydra:totalItems": 1, diff --git a/features/graphql/query.feature b/features/graphql/query.feature index 8fd4058ca3e..93285285dec 100644 --- a/features/graphql/query.feature +++ b/features/graphql/query.feature @@ -126,6 +126,18 @@ Feature: GraphQL query support And the header "Content-Type" should be equal to "application/json" And the JSON node "data.dummyGroup.foo" should be equal to "Foo #1" + Scenario: Query a serialized name + Given there is a DummyCar entity with related colors + When I send the following GraphQL request: + """ + { + dummyCar(id: "/dummy_cars/1") { + carBrand + } + } + """ + Then the JSON node "data.dummyCar.carBrand" should be equal to "DummyBrand" + Scenario: Fetch only the internal id When I send the following GraphQL request: """ diff --git a/src/GraphQl/Serializer/SerializerContextBuilder.php b/src/GraphQl/Serializer/SerializerContextBuilder.php index 49803d82751..a822c35474d 100644 --- a/src/GraphQl/Serializer/SerializerContextBuilder.php +++ b/src/GraphQl/Serializer/SerializerContextBuilder.php @@ -16,6 +16,7 @@ use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; use GraphQL\Type\Definition\ResolveInfo; +use Symfony\Component\Serializer\NameConverter\AdvancedNameConverterInterface; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; /** @@ -45,10 +46,6 @@ public function create(?string $resourceClass, string $operationName, array $res 'graphql_operation_name' => $operationName, ]; - if ($normalization) { - $context['attributes'] = $this->fieldsToAttributes($resourceMetadata, $resolverContext); - } - if (isset($resolverContext['fields'])) { $context['no_resolver_data'] = true; } @@ -61,25 +58,29 @@ public function create(?string $resourceClass, string $operationName, array $res $context = array_merge($resourceMetadata->getGraphqlAttribute($operationName, $key, [], true), $context); } + if ($normalization) { + $context['attributes'] = $this->fieldsToAttributes($resourceClass, $resourceMetadata, $resolverContext, $context); + } + return $context; } /** * Retrieves fields, recursively replaces the "_id" key (the raw id) by "id" (the name of the property expected by the Serializer) and flattens edge and node structures (pagination). */ - private function fieldsToAttributes(?ResourceMetadata $resourceMetadata, array $context): array + private function fieldsToAttributes(?string $resourceClass, ?ResourceMetadata $resourceMetadata, array $resolverContext, array $context): array { - if (isset($context['fields'])) { - $fields = $context['fields']; + if (isset($resolverContext['fields'])) { + $fields = $resolverContext['fields']; } else { /** @var ResolveInfo $info */ - $info = $context['info']; + $info = $resolverContext['info']; $fields = $info->getFieldSelection(PHP_INT_MAX); } - $attributes = $this->replaceIdKeys($fields['edges']['node'] ?? $fields); + $attributes = $this->replaceIdKeys($fields['edges']['node'] ?? $fields, $resourceClass, $context); - if ($context['is_mutation'] || $context['is_subscription']) { + if ($resolverContext['is_mutation'] || $resolverContext['is_subscription']) { if (!$resourceMetadata) { throw new \LogicException('ResourceMetadata should always exist for a mutation or a subscription.'); } @@ -92,7 +93,7 @@ private function fieldsToAttributes(?ResourceMetadata $resourceMetadata, array $ return $attributes; } - private function replaceIdKeys(array $fields): array + private function replaceIdKeys(array $fields, ?string $resourceClass, array $context): array { $denormalizedFields = []; @@ -103,14 +104,21 @@ private function replaceIdKeys(array $fields): array continue; } - $denormalizedFields[$this->denormalizePropertyName((string) $key)] = \is_array($fields[$key]) ? $this->replaceIdKeys($fields[$key]) : $value; + $denormalizedFields[$this->denormalizePropertyName((string) $key, $resourceClass, $context)] = \is_array($fields[$key]) ? $this->replaceIdKeys($fields[$key], $resourceClass, $context) : $value; } return $denormalizedFields; } - private function denormalizePropertyName(string $property): string + private function denormalizePropertyName(string $property, ?string $resourceClass, array $context): string { - return null !== $this->nameConverter ? $this->nameConverter->denormalize($property) : $property; + if (null === $this->nameConverter) { + return $property; + } + if ($this->nameConverter instanceof AdvancedNameConverterInterface) { + return $this->nameConverter->denormalize($property, $resourceClass, null, $context); + } + + return $this->nameConverter->denormalize($property); } } diff --git a/src/GraphQl/Type/FieldsBuilder.php b/src/GraphQl/Type/FieldsBuilder.php index aef61d96449..c073edaeebe 100644 --- a/src/GraphQl/Type/FieldsBuilder.php +++ b/src/GraphQl/Type/FieldsBuilder.php @@ -29,6 +29,7 @@ use Psr\Container\ContainerInterface; use Symfony\Component\Config\Definition\Exception\InvalidTypeException; use Symfony\Component\PropertyInfo\Type; +use Symfony\Component\Serializer\NameConverter\AdvancedNameConverterInterface; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; /** @@ -497,6 +498,13 @@ private function convertType(Type $type, bool $input, ?string $queryName, ?strin private function normalizePropertyName(string $property, string $resourceClass): string { - return null !== $this->nameConverter ? $this->nameConverter->normalize($property, $resourceClass) : $property; + if (null === $this->nameConverter) { + return $property; + } + if ($this->nameConverter instanceof AdvancedNameConverterInterface) { + return $this->nameConverter->normalize($property, $resourceClass); + } + + return $this->nameConverter->normalize($property); } } diff --git a/tests/Fixtures/TestBundle/Document/DummyCar.php b/tests/Fixtures/TestBundle/Document/DummyCar.php index df23c2cfe9a..ea64c6babe7 100644 --- a/tests/Fixtures/TestBundle/Document/DummyCar.php +++ b/tests/Fixtures/TestBundle/Document/DummyCar.php @@ -110,6 +110,16 @@ class DummyCar */ private $availableAt; + /** + * @var string + * + * @Serializer\Groups({"colors"}) + * @Serializer\SerializedName("carBrand") + * + * @ODM\Field + */ + private $brand = 'DummyBrand'; + public function __construct() { $this->colors = new ArrayCollection(); @@ -191,4 +201,14 @@ public function setAvailableAt(\DateTime $availableAt) { $this->availableAt = $availableAt; } + + public function getBrand(): string + { + return $this->brand; + } + + public function setBrand(string $brand): void + { + $this->brand = $brand; + } } diff --git a/tests/Fixtures/TestBundle/Entity/DummyCar.php b/tests/Fixtures/TestBundle/Entity/DummyCar.php index a6bb99575fe..ad0660f65a9 100644 --- a/tests/Fixtures/TestBundle/Entity/DummyCar.php +++ b/tests/Fixtures/TestBundle/Entity/DummyCar.php @@ -115,6 +115,16 @@ class DummyCar */ private $availableAt; + /** + * @var string + * + * @Serializer\Groups({"colors"}) + * @Serializer\SerializedName("carBrand") + * + * @ORM\Column + */ + private $brand = 'DummyBrand'; + public function __construct() { $this->colors = new ArrayCollection(); @@ -199,4 +209,14 @@ public function setAvailableAt(\DateTime $availableAt) { $this->availableAt = $availableAt; } + + public function getBrand(): string + { + return $this->brand; + } + + public function setBrand(string $brand): void + { + $this->brand = $brand; + } } diff --git a/tests/GraphQl/Serializer/SerializerContextBuilderTest.php b/tests/GraphQl/Serializer/SerializerContextBuilderTest.php index 277a696f6fb..b4a3e4d6072 100644 --- a/tests/GraphQl/Serializer/SerializerContextBuilderTest.php +++ b/tests/GraphQl/Serializer/SerializerContextBuilderTest.php @@ -19,6 +19,8 @@ use ApiPlatform\Core\Tests\Fixtures\TestBundle\Serializer\NameConverter\CustomConverter; use GraphQL\Type\Definition\ResolveInfo; use PHPUnit\Framework\TestCase; +use Prophecy\Argument; +use Symfony\Component\Serializer\NameConverter\AdvancedNameConverterInterface; /** * @author Alan Poulain @@ -36,16 +38,18 @@ protected function setUp(): void { $this->resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); - $this->serializerContextBuilder = new SerializerContextBuilder( - $this->resourceMetadataFactoryProphecy->reveal(), - new CustomConverter() - ); + $this->serializerContextBuilder = $this->buildSerializerContextBuilder(); + } + + private function buildSerializerContextBuilder(?AdvancedNameConverterInterface $advancedNameConverter = null): SerializerContextBuilder + { + return new SerializerContextBuilder($this->resourceMetadataFactoryProphecy->reveal(), $advancedNameConverter ?? new CustomConverter()); } /** * @dataProvider createNormalizationContextProvider */ - public function testCreateNormalizationContext(?string $resourceClass, string $operationName, array $fields, bool $isMutation, bool $isSubscription, bool $noInfo, array $expectedContext, ?string $expectedExceptionClass = null, ?string $expectedExceptionMessage = null): void + public function testCreateNormalizationContext(?string $resourceClass, string $operationName, array $fields, bool $isMutation, bool $isSubscription, bool $noInfo, array $expectedContext, ?AdvancedNameConverterInterface $advancedNameConverter = null, ?string $expectedExceptionClass = null, ?string $expectedExceptionMessage = null): void { $resolverContext = [ 'is_mutation' => $isMutation, @@ -76,13 +80,21 @@ public function testCreateNormalizationContext(?string $resourceClass, string $o $this->expectExceptionMessage($expectedExceptionMessage); } - $context = $this->serializerContextBuilder->create($resourceClass, $operationName, $resolverContext, true); + $serializerContextBuilder = $this->serializerContextBuilder; + if ($advancedNameConverter) { + $serializerContextBuilder = $this->buildSerializerContextBuilder($advancedNameConverter); + } + + $context = $serializerContextBuilder->create($resourceClass, $operationName, $resolverContext, true); $this->assertSame($expectedContext, $context); } public function createNormalizationContextProvider(): array { + $advancedNameConverter = $this->prophesize(AdvancedNameConverterInterface::class); + $advancedNameConverter->denormalize('field', 'myResource', null, Argument::type('array'))->willReturn('denormalizedField'); + return [ 'nominal' => [ $resourceClass = 'myResource', @@ -95,13 +107,33 @@ public function createNormalizationContextProvider(): array 'groups' => ['normalization_group'], 'resource_class' => $resourceClass, 'graphql_operation_name' => $operationName, + 'input' => ['class' => 'inputClass'], + 'output' => ['class' => 'outputClass'], 'attributes' => [ 'id' => 3, 'field' => 'foo', ], + ], + ], + 'nominal with advanced name converter' => [ + $resourceClass = 'myResource', + $operationName = 'item_query', + ['_id' => 3, 'field' => 'foo'], + false, + false, + false, + [ + 'groups' => ['normalization_group'], + 'resource_class' => $resourceClass, + 'graphql_operation_name' => $operationName, 'input' => ['class' => 'inputClass'], 'output' => ['class' => 'outputClass'], + 'attributes' => [ + 'id' => 3, + 'denormalizedField' => 'foo', + ], ], + $advancedNameConverter->reveal(), ], 'nominal collection' => [ $resourceClass = 'myResource', @@ -114,11 +146,11 @@ public function createNormalizationContextProvider(): array 'groups' => ['normalization_group'], 'resource_class' => $resourceClass, 'graphql_operation_name' => $operationName, + 'input' => ['class' => 'inputClass'], + 'output' => ['class' => 'outputClass'], 'attributes' => [ 'nodeField' => 'baz', ], - 'input' => ['class' => 'inputClass'], - 'output' => ['class' => 'outputClass'], ], ], 'no resource class' => [ @@ -147,12 +179,12 @@ public function createNormalizationContextProvider(): array 'groups' => ['normalization_group'], 'resource_class' => $resourceClass, 'graphql_operation_name' => $operationName, + 'input' => ['class' => 'inputClass'], + 'output' => ['class' => 'outputClass'], 'attributes' => [ 'id' => 7, 'related' => ['field' => 'bar'], ], - 'input' => ['class' => 'inputClass'], - 'output' => ['class' => 'outputClass'], ], ], 'mutation without resource class' => [ @@ -163,6 +195,7 @@ public function createNormalizationContextProvider(): array false, false, [], + null, \LogicException::class, 'ResourceMetadata should always exist for a mutation or a subscription.', ], @@ -177,13 +210,13 @@ public function createNormalizationContextProvider(): array 'groups' => ['normalization_group'], 'resource_class' => $resourceClass, 'graphql_operation_name' => $operationName, + 'no_resolver_data' => true, + 'input' => ['class' => 'inputClass'], + 'output' => ['class' => 'outputClass'], 'attributes' => [ 'id' => 7, 'related' => ['field' => 'bar'], ], - 'no_resolver_data' => true, - 'input' => ['class' => 'inputClass'], - 'output' => ['class' => 'outputClass'], ], ], ]; diff --git a/tests/GraphQl/Type/FieldsBuilderTest.php b/tests/GraphQl/Type/FieldsBuilderTest.php index dc95d41a093..ee4001044db 100644 --- a/tests/GraphQl/Type/FieldsBuilderTest.php +++ b/tests/GraphQl/Type/FieldsBuilderTest.php @@ -38,6 +38,7 @@ use Prophecy\Prophecy\ObjectProphecy; use Psr\Container\ContainerInterface; use Symfony\Component\PropertyInfo\Type; +use Symfony\Component\Serializer\NameConverter\AdvancedNameConverterInterface; /** * @author Alan Poulain @@ -96,7 +97,12 @@ protected function setUp(): void $this->itemMutationResolverFactoryProphecy = $this->prophesize(ResolverFactoryInterface::class); $this->itemSubscriptionResolverFactoryProphecy = $this->prophesize(ResolverFactoryInterface::class); $this->filterLocatorProphecy = $this->prophesize(ContainerInterface::class); - $this->fieldsBuilder = new FieldsBuilder($this->propertyNameCollectionFactoryProphecy->reveal(), $this->propertyMetadataFactoryProphecy->reveal(), $this->resourceMetadataFactoryProphecy->reveal(), $this->typesContainerProphecy->reveal(), $this->typeBuilderProphecy->reveal(), $this->typeConverterProphecy->reveal(), $this->itemResolverFactoryProphecy->reveal(), $this->collectionResolverFactoryProphecy->reveal(), $this->itemMutationResolverFactoryProphecy->reveal(), $this->itemSubscriptionResolverFactoryProphecy->reveal(), $this->filterLocatorProphecy->reveal(), new Pagination($this->resourceMetadataFactoryProphecy->reveal()), new CustomConverter(), '__'); + $this->fieldsBuilder = $this->buildFieldsBuilder(); + } + + private function buildFieldsBuilder(?AdvancedNameConverterInterface $advancedNameConverter = null): FieldsBuilder + { + return new FieldsBuilder($this->propertyNameCollectionFactoryProphecy->reveal(), $this->propertyMetadataFactoryProphecy->reveal(), $this->resourceMetadataFactoryProphecy->reveal(), $this->typesContainerProphecy->reveal(), $this->typeBuilderProphecy->reveal(), $this->typeConverterProphecy->reveal(), $this->itemResolverFactoryProphecy->reveal(), $this->collectionResolverFactoryProphecy->reveal(), $this->itemMutationResolverFactoryProphecy->reveal(), $this->itemSubscriptionResolverFactoryProphecy->reveal(), $this->filterLocatorProphecy->reveal(), new Pagination($this->resourceMetadataFactoryProphecy->reveal()), $advancedNameConverter ?? new CustomConverter(), '__'); } public function testGetNodeQueryFields(): void @@ -484,7 +490,7 @@ public function subscriptionFieldsProvider(): array /** * @dataProvider resourceObjectTypeFieldsProvider */ - public function testGetResourceObjectTypeFields(string $resourceClass, ResourceMetadata $resourceMetadata, array $properties, bool $input, ?string $queryName, ?string $mutationName, ?string $subscriptionName, ?array $ioMetadata, array $expectedResourceObjectTypeFields): void + public function testGetResourceObjectTypeFields(string $resourceClass, ResourceMetadata $resourceMetadata, array $properties, bool $input, ?string $queryName, ?string $mutationName, ?string $subscriptionName, ?array $ioMetadata, array $expectedResourceObjectTypeFields, ?AdvancedNameConverterInterface $advancedNameConverter = null): void { $this->propertyNameCollectionFactoryProphecy->create($resourceClass)->willReturn(new PropertyNameCollection(array_keys($properties))); foreach ($properties as $propertyName => $propertyMetadata) { @@ -501,13 +507,20 @@ public function testGetResourceObjectTypeFields(string $resourceClass, ResourceM $this->typeBuilderProphecy->isCollection(Argument::type(Type::class))->willReturn(false); $this->resourceMetadataFactoryProphecy->create('subresourceClass')->willReturn(new ResourceMetadata()); - $resourceObjectTypeFields = $this->fieldsBuilder->getResourceObjectTypeFields($resourceClass, $resourceMetadata, $input, $queryName, $mutationName, $subscriptionName, 0, $ioMetadata); + $fieldsBuilder = $this->fieldsBuilder; + if ($advancedNameConverter) { + $fieldsBuilder = $this->buildFieldsBuilder($advancedNameConverter); + } + $resourceObjectTypeFields = $fieldsBuilder->getResourceObjectTypeFields($resourceClass, $resourceMetadata, $input, $queryName, $mutationName, $subscriptionName, 0, $ioMetadata); $this->assertEquals($expectedResourceObjectTypeFields, $resourceObjectTypeFields); } public function resourceObjectTypeFieldsProvider(): array { + $advancedNameConverter = $this->prophesize(AdvancedNameConverterInterface::class); + $advancedNameConverter->normalize('field', 'resourceClass')->willReturn('normalizedField'); + return [ 'query' => ['resourceClass', new ResourceMetadata(), [ @@ -537,6 +550,25 @@ public function resourceObjectTypeFieldsProvider(): array ], ], ], + 'query with advanced name converter' => ['resourceClass', new ResourceMetadata(), + [ + 'field' => new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING), null, true, false), + ], + false, 'item_query', null, null, null, + [ + 'id' => [ + 'type' => GraphQLType::nonNull(GraphQLType::id()), + ], + 'normalizedField' => [ + 'type' => GraphQLType::nonNull(GraphQLType::string()), + 'description' => null, + 'args' => [], + 'resolve' => null, + 'deprecationReason' => '', + ], + ], + $advancedNameConverter->reveal(), + ], 'query input' => ['resourceClass', new ResourceMetadata(), [ 'property' => new PropertyMetadata(), diff --git a/tests/Util/AnnotationFilterExtractorTraitTest.php b/tests/Util/AnnotationFilterExtractorTraitTest.php index 4a329ac0f42..c3dff404b1d 100644 --- a/tests/Util/AnnotationFilterExtractorTraitTest.php +++ b/tests/Util/AnnotationFilterExtractorTraitTest.php @@ -40,7 +40,7 @@ public function testReadAnnotations() $this->assertEquals($this->extractor->getFilters($reflectionClass), [ 'annotated_api_platform_core_tests_fixtures_test_bundle_entity_dummy_car_api_platform_core_bridge_doctrine_orm_filter_date_filter' => [ - ['properties' => ['id' => 'exclude_null', 'colors' => 'exclude_null', 'name' => 'exclude_null', 'canSell' => 'exclude_null', 'availableAt' => 'exclude_null', 'secondColors' => 'exclude_null', 'thirdColors' => 'exclude_null', 'uuid' => 'exclude_null']], + ['properties' => ['id' => 'exclude_null', 'colors' => 'exclude_null', 'name' => 'exclude_null', 'canSell' => 'exclude_null', 'availableAt' => 'exclude_null', 'brand' => 'exclude_null', 'secondColors' => 'exclude_null', 'thirdColors' => 'exclude_null', 'uuid' => 'exclude_null']], DateFilter::class, ], 'annotated_api_platform_core_tests_fixtures_test_bundle_entity_dummy_car_api_platform_core_bridge_doctrine_orm_filter_boolean_filter' => [ From f52a17ef8ffec4651b65b61ee3fd12626bc4145f Mon Sep 17 00:00:00 2001 From: Alan Poulain Date: Thu, 23 Apr 2020 17:21:44 +0200 Subject: [PATCH 078/160] [GraphQL] Fix serializer context attributes for page-based pagination (#3517) --- CHANGELOG.md | 2 +- features/graphql/collection.feature | 11 +++++++++++ src/GraphQl/Serializer/SerializerContextBuilder.php | 2 +- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a864b0fea9..1a01e43b677 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ * MongoDB: Mercure support (#3290) * GraphQL: Subscription support with Mercure (#3321) * GraphQL: Allow to format GraphQL errors based on exceptions (#3063) -* GraphQL: Add page-based pagination (#3175) +* GraphQL: Add page-based pagination (#3175, #3517) * GraphQL: Possibility to add a custom description for queries, mutations and subscriptions (#3477, #3514) * GraphQL: Support for field name conversion (serialized name) (#3455, #3516) * OpenAPI: Add PHP default values to the documentation (#2386) diff --git a/features/graphql/collection.feature b/features/graphql/collection.feature index 1080036b166..b6cdd7ef027 100644 --- a/features/graphql/collection.feature +++ b/features/graphql/collection.feature @@ -690,6 +690,7 @@ Feature: GraphQL collection support fooDummies(page: 1) { collection { id + name } paginationInfo { itemsPerPage @@ -703,8 +704,11 @@ Feature: GraphQL collection support And the response should be in JSON And the JSON node "data.fooDummies.collection" should have 3 elements And the JSON node "data.fooDummies.collection[0].id" should exist + And the JSON node "data.fooDummies.collection[0].name" should exist And the JSON node "data.fooDummies.collection[1].id" should exist + And the JSON node "data.fooDummies.collection[1].name" should exist And the JSON node "data.fooDummies.collection[2].id" should exist + And the JSON node "data.fooDummies.collection[2].name" should exist And the JSON node "data.fooDummies.paginationInfo.itemsPerPage" should be equal to the number 3 And the JSON node "data.fooDummies.paginationInfo.lastPage" should be equal to the number 2 And the JSON node "data.fooDummies.paginationInfo.totalCount" should be equal to the number 5 @@ -714,6 +718,7 @@ Feature: GraphQL collection support fooDummies(page: 2) { collection { id + name } } } @@ -727,6 +732,7 @@ Feature: GraphQL collection support fooDummies(page: 3) { collection { id + name } } } @@ -744,6 +750,7 @@ Feature: GraphQL collection support fooDummies(page: 1, itemsPerPage: 2) { collection { id + name } } } @@ -752,13 +759,16 @@ Feature: GraphQL collection support And the response should be in JSON And the JSON node "data.fooDummies.collection" should have 2 elements And the JSON node "data.fooDummies.collection[0].id" should exist + And the JSON node "data.fooDummies.collection[0].name" should exist And the JSON node "data.fooDummies.collection[1].id" should exist + And the JSON node "data.fooDummies.collection[1].name" should exist When I send the following GraphQL request: """ { fooDummies(page: 2, itemsPerPage: 2) { collection { id + name } } } @@ -772,6 +782,7 @@ Feature: GraphQL collection support fooDummies(page: 3, itemsPerPage: 2) { collection { id + name } } } diff --git a/src/GraphQl/Serializer/SerializerContextBuilder.php b/src/GraphQl/Serializer/SerializerContextBuilder.php index a822c35474d..5a4b353a4d1 100644 --- a/src/GraphQl/Serializer/SerializerContextBuilder.php +++ b/src/GraphQl/Serializer/SerializerContextBuilder.php @@ -78,7 +78,7 @@ private function fieldsToAttributes(?string $resourceClass, ?ResourceMetadata $r $fields = $info->getFieldSelection(PHP_INT_MAX); } - $attributes = $this->replaceIdKeys($fields['edges']['node'] ?? $fields, $resourceClass, $context); + $attributes = $this->replaceIdKeys($fields['edges']['node'] ?? $fields['collection'] ?? $fields, $resourceClass, $context); if ($resolverContext['is_mutation'] || $resolverContext['is_subscription']) { if (!$resourceMetadata) { From cfc05d1ce5e9e45898cb95ab71abf47e2d579da3 Mon Sep 17 00:00:00 2001 From: MartkCz Date: Mon, 27 Apr 2020 11:23:45 +0200 Subject: [PATCH 079/160] use interface HttpExceptionInterface instead of HttpException (#3533) --- .../Serializer/Exception/HttpExceptionNormalizer.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/GraphQl/Serializer/Exception/HttpExceptionNormalizer.php b/src/GraphQl/Serializer/Exception/HttpExceptionNormalizer.php index 061cfb3199e..4c7e97c2b90 100644 --- a/src/GraphQl/Serializer/Exception/HttpExceptionNormalizer.php +++ b/src/GraphQl/Serializer/Exception/HttpExceptionNormalizer.php @@ -15,7 +15,7 @@ use GraphQL\Error\Error; use GraphQL\Error\FormattedError; -use Symfony\Component\HttpKernel\Exception\HttpException; +use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; /** @@ -32,7 +32,7 @@ final class HttpExceptionNormalizer implements NormalizerInterface */ public function normalize($object, $format = null, array $context = []): array { - /** @var HttpException */ + /** @var HttpExceptionInterface */ $httpException = $object->getPrevious(); $error = FormattedError::createFromException($object); $error['message'] = $httpException->getMessage(); @@ -47,6 +47,6 @@ public function normalize($object, $format = null, array $context = []): array */ public function supportsNormalization($data, $format = null): bool { - return $data instanceof Error && $data->getPrevious() instanceof HttpException; + return $data instanceof Error && $data->getPrevious() instanceof HttpExceptionInterface; } } From d83d50228e2686b7a2f76bfbadf2dec3fd83fdcd Mon Sep 17 00:00:00 2001 From: Julien Lenne Date: Mon, 18 May 2020 23:35:17 +0800 Subject: [PATCH 080/160] change operation to OperationName in the parser (#3568) --- CHANGELOG.md | 2 +- features/bootstrap/GraphqlContext.php | 6 ++-- features/graphql/query.feature | 4 +-- src/GraphQl/Action/EntrypointAction.php | 29 +++++++++---------- tests/GraphQl/Action/EntrypointActionTest.php | 22 +++++++------- 5 files changed, 31 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a01e43b677..f3dfccfed4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ * GraphQL: Add page-based pagination (#3175, #3517) * GraphQL: Possibility to add a custom description for queries, mutations and subscriptions (#3477, #3514) * GraphQL: Support for field name conversion (serialized name) (#3455, #3516) +* GraphQL: **BC** `operation` is now `operationName` to follow the standard (#3568) * OpenAPI: Add PHP default values to the documentation (#2386) * Deprecate using a validation groups generator service not implementing `ApiPlatform\Core\Bridge\Symfony\Validator\ValidationGroupsGeneratorInterface` (#3346) @@ -34,7 +35,6 @@ * HTTP: Location header is only set on POST with a 201 or between 300 and 400 #3497 * GraphQL: Do not allow empty cursor values on `before` or `after` #3360 * Bump versions of Swagger UI, GraphiQL and GraphQL Playground #3510 ->>>>>>> 2.5 ## 2.5.4 diff --git a/features/bootstrap/GraphqlContext.php b/features/bootstrap/GraphqlContext.php index 4845fbd223c..484cd3e1046 100644 --- a/features/bootstrap/GraphqlContext.php +++ b/features/bootstrap/GraphqlContext.php @@ -90,11 +90,11 @@ public function ISendTheGraphqlRequestWithVariables(PyStringNode $variables) } /** - * @When I send the GraphQL request with operation :operation + * @When I send the GraphQL request with operationName :operationName */ - public function ISendTheGraphqlRequestWithOperation(string $operation) + public function ISendTheGraphqlRequestWithOperation(string $operationName) { - $this->graphqlRequest['operation'] = $operation; + $this->graphqlRequest['operationName'] = $operationName; $this->sendGraphqlRequest(); } diff --git a/features/graphql/query.feature b/features/graphql/query.feature index 93285285dec..149d39eaef1 100644 --- a/features/graphql/query.feature +++ b/features/graphql/query.feature @@ -102,13 +102,13 @@ Feature: GraphQL query support } } """ - And I send the GraphQL request with operation "DummyWithId2" + And I send the GraphQL request with operationName "DummyWithId2" Then the response status code should be 200 And the response should be in JSON And the header "Content-Type" should be equal to "application/json" And the JSON node "data.dummyItem.id" should be equal to "/dummies/2" And the JSON node "data.dummyItem.name" should be equal to "Dummy #2" - And I send the GraphQL request with operation "DummyWithId1" + And I send the GraphQL request with operationName "DummyWithId1" And the JSON node "data.dummyItem.name" should be equal to "Dummy #1" Scenario: Use serialization groups diff --git a/src/GraphQl/Action/EntrypointAction.php b/src/GraphQl/Action/EntrypointAction.php index ab3cdccfada..e768918dc21 100644 --- a/src/GraphQl/Action/EntrypointAction.php +++ b/src/GraphQl/Action/EntrypointAction.php @@ -69,13 +69,13 @@ public function __invoke(Request $request): Response } } - [$query, $operation, $variables] = $this->parseRequest($request); + [$query, $operationName, $variables] = $this->parseRequest($request); if (null === $query) { throw new BadRequestHttpException('GraphQL query is not valid.'); } $executionResult = $this->executor - ->executeQuery($this->schemaBuilder->getSchema(), $query, null, null, $variables, $operation) + ->executeQuery($this->schemaBuilder->getSchema(), $query, null, null, $variables, $operationName) ->setErrorFormatter([$this->normalizer, 'normalize']); } catch (\Exception $exception) { $executionResult = (new ExecutionResult(null, [new Error($exception->getMessage(), null, null, null, null, $exception)])) @@ -91,17 +91,17 @@ public function __invoke(Request $request): Response private function parseRequest(Request $request): array { $query = $request->query->get('query'); - $operation = $request->query->get('operation'); + $operationName = $request->query->get('operationName'); if ($variables = $request->query->get('variables', [])) { $variables = $this->decodeVariables($variables); } if (!$request->isMethod('POST')) { - return [$query, $operation, $variables]; + return [$query, $operationName, $variables]; } if ('json' === $request->getContentType()) { - return $this->parseData($query, $operation, $variables, $request->getContent()); + return $this->parseData($query, $operationName, $variables, $request->getContent()); } if ('graphql' === $request->getContentType()) { @@ -109,16 +109,16 @@ private function parseRequest(Request $request): array } if ('multipart' === $request->getContentType()) { - return $this->parseMultipartRequest($query, $operation, $variables, $request->request->all(), $request->files->all()); + return $this->parseMultipartRequest($query, $operationName, $variables, $request->request->all(), $request->files->all()); } - return [$query, $operation, $variables]; + return [$query, $operationName, $variables]; } /** * @throws BadRequestHttpException */ - private function parseData(?string $query, ?string $operation, array $variables, string $jsonContent): array + private function parseData(?string $query, ?string $operationName, array $variables, string $jsonContent): array { if (!\is_array($data = json_decode($jsonContent, true))) { throw new BadRequestHttpException('GraphQL data is not valid JSON.'); @@ -132,24 +132,23 @@ private function parseData(?string $query, ?string $operation, array $variables, $variables = \is_array($data['variables']) ? $data['variables'] : $this->decodeVariables($data['variables']); } - if (isset($data['operation'])) { - $operation = $data['operation']; + if (isset($data['operationName'])) { + $operationName = $data['operationName']; } - return [$query, $operation, $variables]; + return [$query, $operationName, $variables]; } /** * @throws BadRequestHttpException */ - private function parseMultipartRequest(?string $query, ?string $operation, array $variables, array $bodyParameters, array $files): array + private function parseMultipartRequest(?string $query, ?string $operationName, array $variables, array $bodyParameters, array $files): array { if ((null === $operations = $bodyParameters['operations'] ?? null) || (null === $map = $bodyParameters['map'] ?? null)) { throw new BadRequestHttpException('GraphQL multipart request does not respect the specification.'); } - /** @var string $operations */ - [$query, $operation, $variables] = $this->parseData($query, $operation, $variables, $operations); + [$query, $operationName, $variables] = $this->parseData($query, $operationName, $variables, $operations); /** @var string $map */ if (!\is_array($decodedMap = json_decode($map, true))) { @@ -158,7 +157,7 @@ private function parseMultipartRequest(?string $query, ?string $operation, array $variables = $this->applyMapToVariables($decodedMap, $variables, $files); - return [$query, $operation, $variables]; + return [$query, $operationName, $variables]; } /** diff --git a/tests/GraphQl/Action/EntrypointActionTest.php b/tests/GraphQl/Action/EntrypointActionTest.php index 9da2d7edc7f..e0892e3bff6 100644 --- a/tests/GraphQl/Action/EntrypointActionTest.php +++ b/tests/GraphQl/Action/EntrypointActionTest.php @@ -58,7 +58,7 @@ public function testGetHtmlAction(): void public function testGetAction(): void { - $request = new Request(['query' => 'graphqlQuery', 'variables' => '["graphqlVariable"]', 'operation' => 'graphqlOperationName']); + $request = new Request(['query' => 'graphqlQuery', 'variables' => '["graphqlVariable"]', 'operationName' => 'graphqlOperationName']); $request->setRequestFormat('json'); $mockedEntrypoint = $this->getEntrypointAction(); @@ -67,7 +67,7 @@ public function testGetAction(): void public function testPostRawAction(): void { - $request = new Request(['variables' => '["graphqlVariable"]', 'operation' => 'graphqlOperationName'], [], [], [], [], [], 'graphqlQuery'); + $request = new Request(['variables' => '["graphqlVariable"]', 'operationName' => 'graphqlOperationName'], [], [], [], [], [], 'graphqlQuery'); $request->setFormat('graphql', 'application/graphql'); $request->setMethod('POST'); $request->headers->set('Content-Type', 'application/graphql'); @@ -78,7 +78,7 @@ public function testPostRawAction(): void public function testPostJsonAction(): void { - $request = new Request([], [], [], [], [], [], '{"query": "graphqlQuery", "variables": "[\"graphqlVariable\"]", "operation": "graphqlOperationName"}'); + $request = new Request([], [], [], [], [], [], '{"query": "graphqlQuery", "variables": "[\"graphqlVariable\"]", "operationName": "graphqlOperationName"}'); $request->setMethod('POST'); $request->headers->set('Content-Type', 'application/json'); $mockedEntrypoint = $this->getEntrypointAction(); @@ -119,14 +119,14 @@ public function multipartRequestProvider(): array return [ 'upload a single file' => [ - '{"query": "graphqlQuery", "variables": {"file": null}, "operation": "graphqlOperationName"}', + '{"query": "graphqlQuery", "variables": {"file": null}, "operationName": "graphqlOperationName"}', '{"file": ["variables.file"]}', ['file' => $file], ['file' => $file], new JsonResponse(['GraphQL']), ], 'upload multiple files' => [ - '{"query": "graphqlQuery", "variables": {"files": [null, null, null]}, "operation": "graphqlOperationName"}', + '{"query": "graphqlQuery", "variables": {"files": [null, null, null]}, "operationName": "graphqlOperationName"}', '{"0": ["variables.files.0"], "1": ["variables.files.1"], "2": ["variables.files.2"]}', [ '0' => $file, @@ -150,7 +150,7 @@ public function multipartRequestProvider(): array new Response('{"errors":[{"message":"GraphQL multipart request does not respect the specification.","extensions":{"category":"user","status":400}}]}'), ], 'upload without providing map' => [ - '{"query": "graphqlQuery", "variables": {"file": null}, "operation": "graphqlOperationName"}', + '{"query": "graphqlQuery", "variables": {"file": null}, "operationName": "graphqlOperationName"}', null, ['file' => $file], ['file' => null], @@ -164,28 +164,28 @@ public function multipartRequestProvider(): array new Response('{"errors":[{"message":"GraphQL data is not valid JSON.","extensions":{"category":"user","status":400}}]}'), ], 'upload with invalid map JSON' => [ - '{"query": "graphqlQuery", "variables": {"file": null}, "operation": "graphqlOperationName"}', + '{"query": "graphqlQuery", "variables": {"file": null}, "operationName": "graphqlOperationName"}', '{invalid}', ['file' => $file], ['file' => null], new Response('{"errors":[{"message":"GraphQL multipart request map is not valid JSON.","extensions":{"category":"user","status":400}}]}'), ], 'upload with no file' => [ - '{"query": "graphqlQuery", "variables": {"file": null}, "operation": "graphqlOperationName"}', + '{"query": "graphqlQuery", "variables": {"file": null}, "operationName": "graphqlOperationName"}', '{"file": ["file"]}', [], ['file' => null], new Response('{"errors":[{"message":"GraphQL multipart request file has not been sent correctly.","extensions":{"category":"user","status":400}}]}'), ], 'upload with wrong map' => [ - '{"query": "graphqlQuery", "variables": {"file": null}, "operation": "graphqlOperationName"}', + '{"query": "graphqlQuery", "variables": {"file": null}, "operationName": "graphqlOperationName"}', '{"file": ["file"]}', ['file' => $file], ['file' => null], new Response('{"errors":[{"message":"GraphQL multipart request path in map is invalid.","extensions":{"category":"user","status":400}}]}'), ], 'upload when variable path does not exist' => [ - '{"query": "graphqlQuery", "variables": {"file": null}, "operation": "graphqlOperationName"}', + '{"query": "graphqlQuery", "variables": {"file": null}, "operationName": "graphqlOperationName"}', '{"file": ["variables.wrong"]}', ['file' => $file], ['file' => null], @@ -217,7 +217,7 @@ public function testBadMethodAction(): void public function testBadVariablesAction(): void { - $request = new Request(['query' => 'graphqlQuery', 'variables' => 'graphqlVariable', 'operation' => 'graphqlOperationName']); + $request = new Request(['query' => 'graphqlQuery', 'variables' => 'graphqlVariable', 'operationName' => 'graphqlOperationName']); $request->setRequestFormat('json'); $mockedEntrypoint = $this->getEntrypointAction(); From 9ccf57835b3a4f0c54cbde1ef43af193bf689b34 Mon Sep 17 00:00:00 2001 From: Jan Esser Date: Sat, 30 May 2020 09:53:08 +0200 Subject: [PATCH 081/160] Support subresource resourceClass resolving (#3556) --- CHANGELOG.md | 1 + src/Metadata/Extractor/XmlExtractor.php | 2 +- src/Metadata/Extractor/YamlExtractor.php | 3 +++ .../resources_with_parameters.xml | 6 +++++- .../resources_with_parameters.yml | 5 ++++- .../Metadata/Extractor/ExtractorTestCase.php | 20 ++++++++++++++++--- 6 files changed, 31 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5dc3d174e07..a748169cbcd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ * GraphQL: **BC** `operation` is now `operationName` to follow the standard (#3568) * OpenAPI: Add PHP default values to the documentation (#2386) * Deprecate using a validation groups generator service not implementing `ApiPlatform\Core\Bridge\Symfony\Validator\ValidationGroupsGeneratorInterface` (#3346) +* Subresources: subresource resourceClass can now be defined as a container parameter in XML and Yaml definitions ## 2.5.6 diff --git a/src/Metadata/Extractor/XmlExtractor.php b/src/Metadata/Extractor/XmlExtractor.php index e69cf6094d9..3de449b43c4 100644 --- a/src/Metadata/Extractor/XmlExtractor.php +++ b/src/Metadata/Extractor/XmlExtractor.php @@ -120,7 +120,7 @@ private function getProperties(\SimpleXMLElement $resource): array 'attributes' => $this->getAttributes($property, 'attribute'), 'subresource' => $property->subresource ? [ 'collection' => $this->phpize($property->subresource, 'collection', 'bool'), - 'resourceClass' => $this->phpize($property->subresource, 'resourceClass', 'string'), + 'resourceClass' => $this->resolve($this->phpize($property->subresource, 'resourceClass', 'string')), 'maxDepth' => $this->phpize($property->subresource, 'maxDepth', 'integer'), ] : null, ]; diff --git a/src/Metadata/Extractor/YamlExtractor.php b/src/Metadata/Extractor/YamlExtractor.php index 24271fab888..c3cefd33234 100644 --- a/src/Metadata/Extractor/YamlExtractor.php +++ b/src/Metadata/Extractor/YamlExtractor.php @@ -100,6 +100,9 @@ private function extractProperties(array $resourceYaml, string $resourceName, st if (!\is_array($propertyValues)) { throw new InvalidArgumentException(sprintf('"%s" setting is expected to be null or an array, %s given in "%s".', $propertyName, \gettype($propertyValues), $path)); } + if (isset($propertyValues['subresource']['resourceClass'])) { + $propertyValues['subresource']['resourceClass'] = $this->resolve($propertyValues['subresource']['resourceClass']); + } $this->resources[$resourceName]['properties'][$propertyName] = [ 'description' => $this->phpize($propertyValues, 'description', 'string'), diff --git a/tests/Fixtures/FileConfigurations/resources_with_parameters.xml b/tests/Fixtures/FileConfigurations/resources_with_parameters.xml index f3dad90588a..19313053d20 100644 --- a/tests/Fixtures/FileConfigurations/resources_with_parameters.xml +++ b/tests/Fixtures/FileConfigurations/resources_with_parameters.xml @@ -4,7 +4,11 @@ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="https://api-platform.com/schema/metadata https://api-platform.com/schema/metadata/metadata-2.0.xsd"> - + + + + + prophesize(ContainerInterface::class); $containerProphecy->get('dummy_class')->willReturn(\ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy::class); + $containerProphecy->getParameter('dummy_related_owned_class')->willReturn(\ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\RelatedOwnedDummy::class); $containerProphecy->get('file_config_dummy_class')->willReturn(\ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\FileConfigDummy::class); $resources = $this->createExtractor([$this->getResourceWithParametersFile()], $containerProphecy->reveal())->getResources(); @@ -173,7 +174,11 @@ final public function testResourcesParametersResolution() 'subresourceOperations' => null, 'graphql' => null, 'attributes' => null, - 'properties' => null, + 'properties' => [ + 'relatedOwnedDummy' => [ + 'resourceClass' => \ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\RelatedOwnedDummy::class, + ], + ], ], '\ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyBis' => [ 'shortName' => null, @@ -285,6 +290,7 @@ final public function testResourcesParametersResolutionWithTheSymfonyContainer() { $containerProphecy = $this->prophesize(SymfonyContainerInterface::class); $containerProphecy->getParameter('dummy_class')->willReturn(\ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy::class); + $containerProphecy->getParameter('dummy_related_owned_class')->willReturn(\ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\RelatedOwnedDummy::class); $containerProphecy->getParameter('file_config_dummy_class')->willReturn(\ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\FileConfigDummy::class); $resources = $this->createExtractor([$this->getResourceWithParametersFile()], $containerProphecy->reveal())->getResources(); @@ -299,7 +305,11 @@ final public function testResourcesParametersResolutionWithTheSymfonyContainer() 'subresourceOperations' => null, 'graphql' => null, 'attributes' => null, - 'properties' => null, + 'properties' => [ + 'relatedOwnedDummy' => [ + 'resourceClass' => \ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\RelatedOwnedDummy::class, + ], + ], ], '\ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyBis' => [ 'shortName' => null, @@ -421,7 +431,11 @@ final public function testResourcesParametersResolutionWithoutAContainer() 'subresourceOperations' => null, 'graphql' => null, 'attributes' => null, - 'properties' => null, + 'properties' => [ + 'relatedOwnedDummy' => [ + 'resourceClass' => '%dummy_related_owned_class%', + ], + ], ], '%dummy_class%Bis' => [ 'shortName' => null, From 35f85c6c7020b90159c01f23a9f6e9e46a16d629 Mon Sep 17 00:00:00 2001 From: Alan Poulain Date: Tue, 2 Jun 2020 19:40:11 +0200 Subject: [PATCH 082/160] Better handling of relations without Doctrine. (#3591) --- src/GraphQl/Type/TypeConverter.php | 4 ++++ tests/GraphQl/Type/TypeConverterTest.php | 15 ++++++++++++--- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/GraphQl/Type/TypeConverter.php b/src/GraphQl/Type/TypeConverter.php index 9cc2da4d29c..78f6d79e690 100644 --- a/src/GraphQl/Type/TypeConverter.php +++ b/src/GraphQl/Type/TypeConverter.php @@ -62,6 +62,10 @@ public function convertType(Type $type, bool $input, ?string $queryName, ?string return GraphQLType::string(); case Type::BUILTIN_TYPE_ARRAY: case Type::BUILTIN_TYPE_ITERABLE: + if ($resourceType = $this->getResourceType($type, $input, $queryName, $mutationName, $depth)) { + return $resourceType; + } + return 'Iterable'; case Type::BUILTIN_TYPE_OBJECT: if ($input && $depth > 0) { diff --git a/tests/GraphQl/Type/TypeConverterTest.php b/tests/GraphQl/Type/TypeConverterTest.php index 876db1df599..c680ac03111 100644 --- a/tests/GraphQl/Type/TypeConverterTest.php +++ b/tests/GraphQl/Type/TypeConverterTest.php @@ -120,11 +120,12 @@ public function testConvertTypeResourceClassNotFound(): void $this->assertNull($graphqlType); } - public function testConvertTypeResource(): void + /** + * @dataProvider convertTypeResourceProvider + */ + public function testConvertTypeResource(Type $type, ObjectType $expectedGraphqlType): void { $graphqlResourceMetadata = (new ResourceMetadata())->withGraphql(['test']); - $type = new Type(Type::BUILTIN_TYPE_OBJECT, false, null, true, null, new Type(Type::BUILTIN_TYPE_OBJECT, false, 'dummyValue')); - $expectedGraphqlType = new ObjectType(['name' => 'resourceObjectType']); $this->typeBuilderProphecy->isCollection($type)->shouldBeCalled()->willReturn(true); $this->resourceMetadataFactoryProphecy->create('dummyValue')->shouldBeCalled()->willReturn($graphqlResourceMetadata); @@ -134,6 +135,14 @@ public function testConvertTypeResource(): void $this->assertEquals($expectedGraphqlType, $graphqlType); } + public function convertTypeResourceProvider(): array + { + return [ + [new Type(Type::BUILTIN_TYPE_OBJECT, false, null, true, null, new Type(Type::BUILTIN_TYPE_OBJECT, false, 'dummyValue')), new ObjectType(['name' => 'resourceObjectType'])], + [new Type(Type::BUILTIN_TYPE_ARRAY, false, null, true, null, new Type(Type::BUILTIN_TYPE_OBJECT, false, 'dummyValue')), new ObjectType(['name' => 'resourceObjectType'])], + ]; + } + /** * @dataProvider resolveTypeProvider * From 7e7c3eb24b43cb30074b818bc86b4992d604cfca Mon Sep 17 00:00:00 2001 From: Alan Poulain Date: Wed, 3 Jun 2020 14:16:39 +0200 Subject: [PATCH 083/160] Fix subresource DTO --- features/graphql/input_output.feature | 52 +++++++++++++++++-- features/jsonld/input_output.feature | 24 ++++++--- src/GraphQl/Resolver/Stage/ReadStage.php | 9 ++-- .../DummyDtoNoOutputDataPersister.php | 3 +- .../DummyDtoNoInputCollectionDataProvider.php | 3 +- .../SerializableItemDataProvider.php | 5 +- ...myDtoNoInputToOutputDtoDataTransformer.php | 5 +- .../InputDtoDataTransformer.php | 13 +++-- .../OutputDtoDataTransformer.php | 8 ++- .../Document/DummyDtoInputOutput.php | 21 ++++++-- .../TestBundle/Document/DummyDtoNoInput.php | 2 +- .../Document/InputDto.php} | 27 ++++------ .../TestBundle/Dto/Document/OutputDto.php | 42 +++++++++++++++ tests/Fixtures/TestBundle/Dto/InputDto.php | 7 +++ tests/Fixtures/TestBundle/Dto/OutputDto.php | 9 +++- .../TestBundle/Entity/DummyDtoInputOutput.php | 13 +++++ .../SerializableResource.php | 2 +- .../SerializableResourceDenormalizer.php | 5 +- .../GraphQl/Resolver/Stage/ReadStageTest.php | 4 +- 19 files changed, 191 insertions(+), 63 deletions(-) rename tests/Fixtures/TestBundle/{Document/SerializableResource.php => Dto/Document/InputDto.php} (51%) create mode 100644 tests/Fixtures/TestBundle/Dto/Document/OutputDto.php rename tests/Fixtures/TestBundle/{Entity => Model}/SerializableResource.php (92%) diff --git a/features/graphql/input_output.feature b/features/graphql/input_output.feature index ee616ffc5fc..7956bf731e4 100644 --- a/features/graphql/input_output.feature +++ b/features/graphql/input_output.feature @@ -5,12 +5,14 @@ Feature: GraphQL DTO input and output @createSchema Scenario: Retrieve an Output with GraphQL + Given there is a RelatedDummy with 0 friends When I add "Content-Type" header equal to "application/ld+json" And I send a "POST" request to "/dummy_dto_input_outputs" with body: """ { "foo": "test", - "bar": 1 + "bar": 1, + "relatedDummies": ["/related_dummies/1"] } """ Then the response status code should be 201 @@ -22,20 +24,51 @@ Feature: GraphQL DTO input and output "hydra": "http://www.w3.org/ns/hydra/core#", "id": "OutputDto/id", "baz": "OutputDto/baz", - "bat": "OutputDto/bat" + "bat": "OutputDto/bat", + "relatedDummies": "OutputDto/relatedDummies" }, "@type": "DummyDtoInputOutput", "@id": "/dummy_dto_input_outputs/1", "id": 1, "baz": 1, - "bat": "test" + "bat": "test", + "relatedDummies": [ + { + "@context": "/contexts/RelatedDummy", + "@id": "/related_dummies/1", + "@type": "https://schema.org/Product", + "name": "RelatedDummy with friends", + "dummyDate": null, + "thirdLevel": null, + "relatedToDummyFriend": [], + "dummyBoolean": null, + "embeddedDummy": { + "dummyName": null, + "dummyBoolean": null, + "dummyDate": null, + "dummyFloat": null, + "dummyPrice": null, + "symfony": null + }, + "id": 1, + "symfony": "symfony", + "age": null + } + ] } """ When I send the following GraphQL request: """ { dummyDtoInputOutput(id: "/dummy_dto_input_outputs/1") { - _id, id, baz + _id, id, baz, + relatedDummies { + edges { + node { + name + } + } + } } } """ @@ -49,7 +82,16 @@ Feature: GraphQL DTO input and output "dummyDtoInputOutput": { "_id": 1, "id": "/dummy_dto_input_outputs/1", - "baz": 1 + "baz": 1, + "relatedDummies": { + "edges": [ + { + "node": { + "name": "RelatedDummy with friends" + } + } + ] + } } } } diff --git a/features/jsonld/input_output.feature b/features/jsonld/input_output.feature index 256eb610a22..a4be765cead 100644 --- a/features/jsonld/input_output.feature +++ b/features/jsonld/input_output.feature @@ -151,13 +151,15 @@ Feature: JSON-LD DTO input and output "hydra": "http://www.w3.org/ns/hydra/core#", "id": "OutputDto/id", "baz": "OutputDto/baz", - "bat": "OutputDto/bat" + "bat": "OutputDto/bat", + "relatedDummies": "OutputDto/relatedDummies" }, "@type": "DummyDtoInputOutput", "@id": "/dummy_dto_input_outputs/1", "id": 1, "baz": 1, - "bat": "test" + "bat": "test", + "relatedDummies": [] } """ When I add "Accept" header equal to "application/ld+json" @@ -178,13 +180,15 @@ Feature: JSON-LD DTO input and output "hydra": "http://www.w3.org/ns/hydra/core#", "id": "OutputDto/id", "baz": "OutputDto/baz", - "bat": "OutputDto/bat" + "bat": "OutputDto/bat", + "relatedDummies": "OutputDto/relatedDummies" }, "@type": "DummyDtoInputOutput", "@id": "/dummy_dto_input_outputs/1", "id": 1, "baz": 2, - "bat": "test" + "bat": "test", + "relatedDummies": [] } """ @@ -237,13 +241,15 @@ Feature: JSON-LD DTO input and output "hydra": "http://www.w3.org/ns/hydra/core#", "id": "OutputDto/id", "baz": "OutputDto/baz", - "bat": "OutputDto/bat" + "bat": "OutputDto/bat", + "relatedDummies": "OutputDto/relatedDummies" }, "@type": "DummyDtoNoInput", "@id": "/dummy_dto_no_inputs/1", "id": 1, "baz": 1, - "bat": "test" + "bat": "test", + "relatedDummies": [] } """ @@ -260,13 +266,15 @@ Feature: JSON-LD DTO input and output "hydra": "http://www.w3.org/ns/hydra/core#", "id": "OutputDto/id", "baz": "OutputDto/baz", - "bat": "OutputDto/bat" + "bat": "OutputDto/bat", + "relatedDummies": "OutputDto/relatedDummies" }, "@type": "DummyDtoNoInput", "@id": "/dummy_dto_no_inputs/1", "id": 1, "baz": 1, - "bat": "testtest" + "bat": "testtest", + "relatedDummies": [] } """ diff --git a/src/GraphQl/Resolver/Stage/ReadStage.php b/src/GraphQl/Resolver/Stage/ReadStage.php index f74e4657b10..88a17d3fadf 100644 --- a/src/GraphQl/Resolver/Stage/ReadStage.php +++ b/src/GraphQl/Resolver/Stage/ReadStage.php @@ -93,9 +93,10 @@ public function __invoke(?string $resourceClass, ?string $rootClass, string $ope $source = $context['source']; /** @var ResolveInfo $info */ $info = $context['info']; - if (isset($source[$rootProperty = $info->fieldName], $source[ItemNormalizer::ITEM_IDENTIFIERS_KEY])) { + if (isset($source[$rootProperty = $info->fieldName], $source[ItemNormalizer::ITEM_IDENTIFIERS_KEY], $source[ItemNormalizer::ITEM_RESOURCE_CLASS_KEY])) { $rootResolvedFields = $source[ItemNormalizer::ITEM_IDENTIFIERS_KEY]; - $subresourceCollection = $this->getSubresource($rootClass, $rootResolvedFields, $rootProperty, $resourceClass, $normalizationContext, $operationName); + $rootResolvedClass = $source[ItemNormalizer::ITEM_RESOURCE_CLASS_KEY]; + $subresourceCollection = $this->getSubresource($rootResolvedClass, $rootResolvedFields, $rootProperty, $resourceClass, $normalizationContext, $operationName); if (!is_iterable($subresourceCollection)) { throw new \UnexpectedValueException('Expected subresource collection to be iterable.'); } @@ -148,12 +149,12 @@ private function getNormalizedFilters(array $args): array /** * @return iterable|object|null */ - private function getSubresource(string $rootClass, array $rootResolvedFields, string $rootProperty, string $subresourceClass, array $normalizationContext, string $operationName) + private function getSubresource(string $rootResolvedClass, array $rootResolvedFields, string $rootProperty, string $subresourceClass, array $normalizationContext, string $operationName) { $resolvedIdentifiers = []; $rootIdentifiers = array_keys($rootResolvedFields); foreach ($rootIdentifiers as $rootIdentifier) { - $resolvedIdentifiers[] = [$rootIdentifier, $rootClass]; + $resolvedIdentifiers[] = [$rootIdentifier, $rootResolvedClass]; } return $this->subresourceDataProvider->getSubresource($subresourceClass, $rootResolvedFields, $normalizationContext + [ diff --git a/tests/Fixtures/TestBundle/DataPersister/DummyDtoNoOutputDataPersister.php b/tests/Fixtures/TestBundle/DataPersister/DummyDtoNoOutputDataPersister.php index 8cef78df2a2..7c19abc1917 100644 --- a/tests/Fixtures/TestBundle/DataPersister/DummyDtoNoOutputDataPersister.php +++ b/tests/Fixtures/TestBundle/DataPersister/DummyDtoNoOutputDataPersister.php @@ -15,6 +15,7 @@ use ApiPlatform\Core\DataPersister\DataPersisterInterface; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\DummyDtoNoOutput as DummyDtoNoOutputDocument; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Dto\Document\InputDto as InputDtoDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Dto\InputDto; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyDtoNoOutput; use Doctrine\Common\Persistence\ManagerRegistry; @@ -33,7 +34,7 @@ public function __construct(ManagerRegistry $registry) */ public function supports($data): bool { - return $data instanceof InputDto; + return $data instanceof InputDto || $data instanceof InputDtoDocument; } /** diff --git a/tests/Fixtures/TestBundle/DataProvider/DummyDtoNoInputCollectionDataProvider.php b/tests/Fixtures/TestBundle/DataProvider/DummyDtoNoInputCollectionDataProvider.php index 85d4e46327a..b426101e8b6 100644 --- a/tests/Fixtures/TestBundle/DataProvider/DummyDtoNoInputCollectionDataProvider.php +++ b/tests/Fixtures/TestBundle/DataProvider/DummyDtoNoInputCollectionDataProvider.php @@ -17,6 +17,7 @@ use ApiPlatform\Core\DataProvider\ContextAwareCollectionDataProviderInterface; use ApiPlatform\Core\DataProvider\RestrictedDataProviderInterface; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\DummyDtoNoInput as DummyDtoNoInputDocument; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Dto\Document\OutputDto as OutputDtoDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Dto\OutputDto; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyDtoNoInput; use Doctrine\Common\Persistence\ManagerRegistry; @@ -50,7 +51,7 @@ public function getCollection(string $resourceClass, string $operationName = nul $dummyDtos = $this->registry->getManagerForClass($resourceClass)->getRepository($resourceClass)->findAll(); $objects = []; foreach ($dummyDtos as $dummyDto) { - $object = new OutputDto(); + $object = DummyDtoNoInput::class === $resourceClass ? new OutputDto() : new OutputDtoDocument(); $object->bat = $dummyDto->lorem; $object->baz = $dummyDto->ipsum; $objects[] = $object; diff --git a/tests/Fixtures/TestBundle/DataProvider/SerializableItemDataProvider.php b/tests/Fixtures/TestBundle/DataProvider/SerializableItemDataProvider.php index 8e677953500..6b5e8b8823a 100644 --- a/tests/Fixtures/TestBundle/DataProvider/SerializableItemDataProvider.php +++ b/tests/Fixtures/TestBundle/DataProvider/SerializableItemDataProvider.php @@ -17,8 +17,7 @@ use ApiPlatform\Core\DataProvider\RestrictedDataProviderInterface; use ApiPlatform\Core\DataProvider\SerializerAwareDataProviderInterface; use ApiPlatform\Core\DataProvider\SerializerAwareDataProviderTrait; -use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\SerializableResource as SerializableResourceDocument; -use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\SerializableResource; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Model\SerializableResource; /** * @author Vincent Chalamon @@ -47,6 +46,6 @@ public function getItem(string $resourceClass, $id, string $operationName = null */ public function supports(string $resourceClass, string $operationName = null, array $context = []): bool { - return \in_array($resourceClass, [SerializableResource::class, SerializableResourceDocument::class], true); + return SerializableResource::class === $resourceClass; } } diff --git a/tests/Fixtures/TestBundle/DataTransformer/DummyDtoNoInputToOutputDtoDataTransformer.php b/tests/Fixtures/TestBundle/DataTransformer/DummyDtoNoInputToOutputDtoDataTransformer.php index 8f70d5b519b..aa35c9c9eba 100644 --- a/tests/Fixtures/TestBundle/DataTransformer/DummyDtoNoInputToOutputDtoDataTransformer.php +++ b/tests/Fixtures/TestBundle/DataTransformer/DummyDtoNoInputToOutputDtoDataTransformer.php @@ -15,6 +15,7 @@ use ApiPlatform\Core\DataTransformer\DataTransformerInterface; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\DummyDtoNoInput as DummyDtoNoInputDocument; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Dto\Document\OutputDto as OutputDtoDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Dto\OutputDto; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyDtoNoInput; @@ -29,7 +30,7 @@ public function transform($object, string $to, array $context = []) throw new \InvalidArgumentException(); } - $output = new OutputDto(); + $output = $object instanceof DummyDtoNoInput ? new OutputDto() : new OutputDtoDocument(); $output->id = $object->getId(); $output->bat = (string) $object->lorem; $output->baz = (float) $object->ipsum; @@ -42,6 +43,6 @@ public function transform($object, string $to, array $context = []) */ public function supportsTransformation($data, string $to, array $context = []): bool { - return ($data instanceof DummyDtoNoInput || $data instanceof DummyDtoNoInputDocument) && OutputDto::class === $to; + return ($data instanceof DummyDtoNoInput || $data instanceof DummyDtoNoInputDocument) && \in_array($to, [OutputDto::class, OutputDtoDocument::class], true); } } diff --git a/tests/Fixtures/TestBundle/DataTransformer/InputDtoDataTransformer.php b/tests/Fixtures/TestBundle/DataTransformer/InputDtoDataTransformer.php index 454fc45631a..9f5a5ee3e05 100644 --- a/tests/Fixtures/TestBundle/DataTransformer/InputDtoDataTransformer.php +++ b/tests/Fixtures/TestBundle/DataTransformer/InputDtoDataTransformer.php @@ -16,6 +16,7 @@ use ApiPlatform\Core\DataTransformer\DataTransformerInterface; use ApiPlatform\Core\Serializer\AbstractItemNormalizer; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\DummyDtoInputOutput as DummyDtoInputOutputDocument; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Dto\Document\InputDto as InputDtoDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Dto\InputDto; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyDtoInputOutput; @@ -26,17 +27,15 @@ final class InputDtoDataTransformer implements DataTransformerInterface */ public function transform($object, string $to, array $context = []) { - /** - * @var \ApiPlatform\Core\Tests\Fixtures\TestBundle\Dto\InputDto - */ + /** @var InputDtoDocument|InputDto */ $data = $object; - /** - * @var \ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyDtoInputOutput - */ + /** @var DummyDtoInputOutputDocument|DummyDtoInputOutput */ $resourceObject = $context[AbstractItemNormalizer::OBJECT_TO_POPULATE] ?? new $context['resource_class'](); $resourceObject->str = $data->foo; $resourceObject->num = $data->bar; + // @phpstan-ignore-next-line + $resourceObject->relatedDummies = $data->relatedDummies; return $resourceObject; } @@ -50,6 +49,6 @@ public function supportsTransformation($object, string $to, array $context = []) return false; } - return (DummyDtoInputOutput::class === $to || DummyDtoInputOutputDocument::class === $to) && (InputDto::class === $context['input']['class']); + return \in_array($to, [DummyDtoInputOutput::class, DummyDtoInputOutputDocument::class], true) && \in_array($context['input']['class'], [InputDto::class, InputDtoDocument::class], true); } } diff --git a/tests/Fixtures/TestBundle/DataTransformer/OutputDtoDataTransformer.php b/tests/Fixtures/TestBundle/DataTransformer/OutputDtoDataTransformer.php index 6ff8ebaff0a..3b1644edc23 100644 --- a/tests/Fixtures/TestBundle/DataTransformer/OutputDtoDataTransformer.php +++ b/tests/Fixtures/TestBundle/DataTransformer/OutputDtoDataTransformer.php @@ -13,8 +13,10 @@ namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\DataTransformer; +use ApiPlatform\Core\DataProvider\ArrayPaginator; use ApiPlatform\Core\DataTransformer\DataTransformerInterface; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\DummyDtoInputOutput as DummyDtoInputOutputDocument; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Dto\Document\OutputDto as OutputDtoDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Dto\OutputDto; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyDtoInputOutput; @@ -29,10 +31,12 @@ public function transform($object, string $to, array $context = []) throw new \InvalidArgumentException(); } - $output = new OutputDto(); + $output = $object instanceof DummyDtoInputOutput ? new OutputDto() : new OutputDtoDocument(); $output->id = $object->id; $output->bat = (string) $object->str; $output->baz = (float) $object->num; + // @phpstan-ignore-next-line + $output->relatedDummies = new ArrayPaginator($object->relatedDummies->toArray(), 0, \count($object->relatedDummies->toArray())); return $output; } @@ -42,6 +46,6 @@ public function transform($object, string $to, array $context = []) */ public function supportsTransformation($data, string $to, array $context = []): bool { - return ($data instanceof DummyDtoInputOutput || $data instanceof DummyDtoInputOutputDocument) && OutputDto::class === $to; + return ($data instanceof DummyDtoInputOutput || $data instanceof DummyDtoInputOutputDocument) && \in_array($to, [OutputDto::class, OutputDtoDocument::class], true); } } diff --git a/tests/Fixtures/TestBundle/Document/DummyDtoInputOutput.php b/tests/Fixtures/TestBundle/Document/DummyDtoInputOutput.php index a7805171051..7f7fa362c98 100644 --- a/tests/Fixtures/TestBundle/Document/DummyDtoInputOutput.php +++ b/tests/Fixtures/TestBundle/Document/DummyDtoInputOutput.php @@ -14,8 +14,10 @@ namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Document; use ApiPlatform\Core\Annotation\ApiResource; -use ApiPlatform\Core\Tests\Fixtures\TestBundle\Dto\InputDto; -use ApiPlatform\Core\Tests\Fixtures\TestBundle\Dto\OutputDto; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Dto\Document\InputDto; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Dto\Document\OutputDto; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; /** @@ -28,6 +30,11 @@ */ class DummyDtoInputOutput { + public function __construct() + { + $this->relatedDummies = new ArrayCollection(); + } + /** * @var int The id * @ODM\Id(strategy="INCREMENT", type="integer", nullable=true) @@ -35,14 +42,20 @@ class DummyDtoInputOutput public $id; /** - * @var int The id + * @var string * @ODM\Field */ public $str; /** - * @var int The id + * @var int * @ODM\Field(type="float") */ public $num; + + /** + * @var Collection + * @ODM\ReferenceMany(targetDocument=RelatedDummy::class, storeAs="id", nullable=true) + */ + public $relatedDummies; } diff --git a/tests/Fixtures/TestBundle/Document/DummyDtoNoInput.php b/tests/Fixtures/TestBundle/Document/DummyDtoNoInput.php index ccdf40e7e71..9a475f56575 100644 --- a/tests/Fixtures/TestBundle/Document/DummyDtoNoInput.php +++ b/tests/Fixtures/TestBundle/Document/DummyDtoNoInput.php @@ -16,7 +16,7 @@ use ApiPlatform\Core\Annotation\ApiResource; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Controller\DummyDtoNoInput\CreateItemAction; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Controller\DummyDtoNoInput\DoubleBatAction; -use ApiPlatform\Core\Tests\Fixtures\TestBundle\Dto\OutputDto; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Dto\Document\OutputDto; use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; /** diff --git a/tests/Fixtures/TestBundle/Document/SerializableResource.php b/tests/Fixtures/TestBundle/Dto/Document/InputDto.php similarity index 51% rename from tests/Fixtures/TestBundle/Document/SerializableResource.php rename to tests/Fixtures/TestBundle/Dto/Document/InputDto.php index 2d00ba18fed..4fa680504cc 100644 --- a/tests/Fixtures/TestBundle/Document/SerializableResource.php +++ b/tests/Fixtures/TestBundle/Dto/Document/InputDto.php @@ -11,34 +11,27 @@ declare(strict_types=1); -namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Document; +namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Dto\Document; -use ApiPlatform\Core\Annotation\ApiProperty; -use ApiPlatform\Core\Annotation\ApiResource; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\RelatedDummy as DocumentRelatedDummy; /** - * Resource linked to an external API. - * - * @ApiResource - * - * @author Vincent Chalamon + * @author Kévin Dunglas */ -class SerializableResource +class InputDto { - /** - * @var int - * - * @ApiProperty(identifier=true) - */ - public $id; - /** * @var string */ public $foo; /** - * @var string + * @var int */ public $bar; + + /** + * @var DocumentRelatedDummy[] + */ + public $relatedDummies; } diff --git a/tests/Fixtures/TestBundle/Dto/Document/OutputDto.php b/tests/Fixtures/TestBundle/Dto/Document/OutputDto.php new file mode 100644 index 00000000000..9e8d3e22258 --- /dev/null +++ b/tests/Fixtures/TestBundle/Dto/Document/OutputDto.php @@ -0,0 +1,42 @@ + + * + * 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\Tests\Fixtures\TestBundle\Dto\Document; + +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\RelatedDummy as DocumentRelatedDummy; + +/** + * @author Kévin Dunglas + */ +class OutputDto +{ + /** + * @var int + */ + public $id; + + /** + * @var float + */ + public $baz; + + /** + * @var string + */ + public $bat; + + /** + * @var DocumentRelatedDummy[] + */ + public $relatedDummies = []; +} diff --git a/tests/Fixtures/TestBundle/Dto/InputDto.php b/tests/Fixtures/TestBundle/Dto/InputDto.php index 6ab35beebb0..489ff39b48a 100644 --- a/tests/Fixtures/TestBundle/Dto/InputDto.php +++ b/tests/Fixtures/TestBundle/Dto/InputDto.php @@ -13,6 +13,8 @@ namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Dto; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\RelatedDummy; + /** * @author Kévin Dunglas */ @@ -27,4 +29,9 @@ class InputDto * @var int */ public $bar; + + /** + * @var RelatedDummy[] + */ + public $relatedDummies; } diff --git a/tests/Fixtures/TestBundle/Dto/OutputDto.php b/tests/Fixtures/TestBundle/Dto/OutputDto.php index 46c67caf048..0dd4cf5fe8e 100644 --- a/tests/Fixtures/TestBundle/Dto/OutputDto.php +++ b/tests/Fixtures/TestBundle/Dto/OutputDto.php @@ -13,9 +13,9 @@ namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Dto; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\RelatedDummy; + /** - * OutputDto. - * * @author Kévin Dunglas */ class OutputDto @@ -34,4 +34,9 @@ class OutputDto * @var string */ public $bat; + + /** + * @var RelatedDummy[] + */ + public $relatedDummies = []; } diff --git a/tests/Fixtures/TestBundle/Entity/DummyDtoInputOutput.php b/tests/Fixtures/TestBundle/Entity/DummyDtoInputOutput.php index 9f42ba8e157..60e6a6fe634 100644 --- a/tests/Fixtures/TestBundle/Entity/DummyDtoInputOutput.php +++ b/tests/Fixtures/TestBundle/Entity/DummyDtoInputOutput.php @@ -16,6 +16,8 @@ use ApiPlatform\Core\Annotation\ApiResource; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Dto\InputDto; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Dto\OutputDto; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; /** @@ -28,6 +30,11 @@ */ class DummyDtoInputOutput { + public function __construct() + { + $this->relatedDummies = new ArrayCollection(); + } + /** * @var int The id * @ORM\Column(type="integer") @@ -47,4 +54,10 @@ class DummyDtoInputOutput * @ORM\Column(type="float") */ public $num; + + /** + * @var Collection + * @ORM\ManyToMany(targetEntity="RelatedDummy") + */ + public $relatedDummies; } diff --git a/tests/Fixtures/TestBundle/Entity/SerializableResource.php b/tests/Fixtures/TestBundle/Model/SerializableResource.php similarity index 92% rename from tests/Fixtures/TestBundle/Entity/SerializableResource.php rename to tests/Fixtures/TestBundle/Model/SerializableResource.php index 619e42b505b..60615c6b2ef 100644 --- a/tests/Fixtures/TestBundle/Entity/SerializableResource.php +++ b/tests/Fixtures/TestBundle/Model/SerializableResource.php @@ -11,7 +11,7 @@ declare(strict_types=1); -namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity; +namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Model; use ApiPlatform\Core\Annotation\ApiProperty; use ApiPlatform\Core\Annotation\ApiResource; diff --git a/tests/Fixtures/TestBundle/Serializer/Denormalizer/SerializableResourceDenormalizer.php b/tests/Fixtures/TestBundle/Serializer/Denormalizer/SerializableResourceDenormalizer.php index 9ce4a185ad1..9d2e8f52df1 100644 --- a/tests/Fixtures/TestBundle/Serializer/Denormalizer/SerializableResourceDenormalizer.php +++ b/tests/Fixtures/TestBundle/Serializer/Denormalizer/SerializableResourceDenormalizer.php @@ -13,8 +13,7 @@ namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Serializer\Denormalizer; -use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\SerializableResource as SerializableResourceDocument; -use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\SerializableResource; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Model\SerializableResource; use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; /** @@ -40,6 +39,6 @@ public function denormalize($data, $class, $format = null, array $context = []) */ public function supportsDenormalization($data, $type, $format = null) { - return 'json' === $format && \in_array($type, [SerializableResource::class, SerializableResourceDocument::class], true) && \is_array($data); + return 'json' === $format && SerializableResource::class === $type && \is_array($data); } } diff --git a/tests/GraphQl/Resolver/Stage/ReadStageTest.php b/tests/GraphQl/Resolver/Stage/ReadStageTest.php index d4138bac209..461087c116b 100644 --- a/tests/GraphQl/Resolver/Stage/ReadStageTest.php +++ b/tests/GraphQl/Resolver/Stage/ReadStageTest.php @@ -209,7 +209,7 @@ public function testApplyCollection(array $args, ?string $rootClass, ?array $sou $this->subresourceDataProviderProphecy->getSubresource($resourceClass, ['id' => 3], $normalizationContext + ['filters' => $expectedFilters, 'property' => $fieldName, 'identifiers' => [['id', $resourceClass]], 'collection' => true], $operationName)->willReturn(['subresource']); - $this->collectionDataProviderProphecy->getCollection($resourceClass, $operationName, $normalizationContext + ['filters' => $expectedFilters])->willReturn($expectedResult); + $this->collectionDataProviderProphecy->getCollection($resourceClass, $operationName, $normalizationContext + ['filters' => $expectedFilters])->willReturn([]); $result = ($this->readStage)($resourceClass, $rootClass, $operationName, $context); @@ -230,7 +230,7 @@ public function collectionProvider(): array 'with subresource' => [ [], 'myResource', - ['subresource' => [], ItemNormalizer::ITEM_IDENTIFIERS_KEY => ['id' => 3]], + ['subresource' => [], ItemNormalizer::ITEM_IDENTIFIERS_KEY => ['id' => 3], ItemNormalizer::ITEM_RESOURCE_CLASS_KEY => 'myResource'], [], ['subresource'], ], From 3c849649a58483d95172283d06468fc5e86be633 Mon Sep 17 00:00:00 2001 From: Alan Poulain Date: Wed, 3 Jun 2020 14:45:24 +0200 Subject: [PATCH 084/160] Fix MongoDB unit tests From 6e388f80717d145558df2fe0a3c623fc350a75a1 Mon Sep 17 00:00:00 2001 From: Alan Poulain Date: Wed, 3 Jun 2020 14:49:14 +0200 Subject: [PATCH 085/160] Fix MongoDB Behat tests --- tests/Fixtures/TestBundle/Document/Dummy.php | 4 ---- 1 file changed, 4 deletions(-) diff --git a/tests/Fixtures/TestBundle/Document/Dummy.php b/tests/Fixtures/TestBundle/Document/Dummy.php index a25c1bff186..60fa247c0bc 100644 --- a/tests/Fixtures/TestBundle/Document/Dummy.php +++ b/tests/Fixtures/TestBundle/Document/Dummy.php @@ -221,10 +221,6 @@ public function getDescription() return $this->description; } - public function hasRole($role) - { - } - public function getFoo() { return $this->foo; From f56cd4a8bb20839f785704a1272bc480b5ea45f0 Mon Sep 17 00:00:00 2001 From: "Alexander M. Turek" Date: Thu, 4 Jun 2020 18:29:01 +0200 Subject: [PATCH 086/160] Handle deprecations from Symfony 5.1 (#3589) --- .../ApiPlatformExtension.php | 26 +++++++++++++++++++ .../DependencyInjection/Configuration.php | 12 +++++++-- .../Symfony/Bundle/Resources/config/api.xml | 11 ++------ .../Bundle/Resources/config/filter.xml | 1 - .../Resources/config/nelmio_api_doc.xml | 4 --- .../ApiPlatformExtensionTest.php | 10 +++++++ 6 files changed, 48 insertions(+), 16 deletions(-) diff --git a/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index 68323417c8a..bf48df38a3b 100644 --- a/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -43,6 +43,7 @@ use Symfony\Component\Config\Resource\DirectoryResource; use Symfony\Component\DependencyInjection\ChildDefinition; use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Definition; use Symfony\Component\DependencyInjection\Exception\RuntimeException; use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface; use Symfony\Component\DependencyInjection\Loader\XmlFileLoader; @@ -147,6 +148,19 @@ private function registerCommonConfiguration(ContainerBuilder $container, array $loader->load('data_provider.xml'); $loader->load('filter.xml'); + $container->getDefinition('api_platform.operation_method_resolver') + ->setDeprecated(...$this->buildDeprecationArgs('2.5', 'The "%service_id%" service is deprecated since API Platform 2.5.')); + $container->getDefinition('api_platform.formats_provider') + ->setDeprecated(...$this->buildDeprecationArgs('2.5', 'The "%service_id%" service is deprecated since API Platform 2.5.')); + $container->getAlias('ApiPlatform\Core\Api\OperationAwareFormatsProviderInterface') + ->setDeprecated(...$this->buildDeprecationArgs('2.5', 'The "%alias_id%" alias is deprecated since API Platform 2.5.')); + $container->getDefinition('api_platform.operation_path_resolver.underscore') + ->setDeprecated(...$this->buildDeprecationArgs('2.1', 'The "%service_id%" service is deprecated since API Platform 2.1 and will be removed in 3.0. Use "api_platform.path_segment_name_generator.underscore" instead.')); + $container->getDefinition('api_platform.operation_path_resolver.dash') + ->setDeprecated(...$this->buildDeprecationArgs('2.1', 'The "%service_id%" service is deprecated since API Platform 2.1 and will be removed in 3.0. Use "api_platform.path_segment_name_generator.dash" instead.')); + $container->getDefinition('api_platform.filters') + ->setDeprecated(...$this->buildDeprecationArgs('2.1', 'The "%service_id%" service is deprecated since 2.1 and will be removed in 3.0. Use the "api_platform.filter_locator" service instead.')); + if (class_exists(Uuid::class)) { $loader->load('ramsey_uuid.xml'); } @@ -460,6 +474,11 @@ private function registerLegacyBundlesConfiguration(ContainerBuilder $container, if (isset($bundles['NelmioApiDocBundle']) && $config['enable_nelmio_api_doc']) { $loader->load('nelmio_api_doc.xml'); + + $container->getDefinition('api_platform.nelmio_api_doc.annotations_provider') + ->setDeprecated(...$this->buildDeprecationArgs('2.2', 'The "%service_id%" service is deprecated since API Platform 2.2 and will be removed in 3.0. NelmioApiDocBundle 3 has native support for API Platform.')); + $container->getDefinition('api_platform.nelmio_api_doc.parser') + ->setDeprecated(...$this->buildDeprecationArgs('2.2', 'The "%service_id%" service is deprecated since API Platform 2.2 and will be removed in 3.0. NelmioApiDocBundle 3 has native support for API Platform.')); } } @@ -668,4 +687,11 @@ private function registerSecurityConfiguration(ContainerBuilder $container, XmlF $loader->load('security.xml'); } } + + private function buildDeprecationArgs(string $version, string $message): array + { + return method_exists(Definition::class, 'getDeprecation') + ? ['api-platform/core', $version, $message] + : [true, $message]; + } } diff --git a/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php b/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php index c3485902af5..d740813c86c 100644 --- a/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php +++ b/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php @@ -27,6 +27,7 @@ use Symfony\Bundle\FullStack; use Symfony\Bundle\MercureBundle\MercureBundle; use Symfony\Bundle\TwigBundle\TwigBundle; +use Symfony\Component\Config\Definition\BaseNode; use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition; use Symfony\Component\Config\Definition\Builder\TreeBuilder; use Symfony\Component\Config\Definition\ConfigurationInterface; @@ -87,7 +88,7 @@ public function getConfigTreeBuilder() ->booleanNode('show_webby')->defaultTrue()->info('If true, show Webby on the documentation page')->end() ->scalarNode('default_operation_path_resolver') ->defaultValue('api_platform.operation_path_resolver.underscore') - ->setDeprecated('The use of the `default_operation_path_resolver` has been deprecated in 2.1 and will be removed in 3.0. Use `path_segment_name_generator` instead.') + ->setDeprecated(...$this->buildDeprecationArgs('2.1', 'The use of the `default_operation_path_resolver` has been deprecated in 2.1 and will be removed in 3.0. Use `path_segment_name_generator` instead.')) ->info('Specify the default operation path resolver to use for generating resources operations path.') ->end() ->scalarNode('name_converter')->defaultNull()->info('Specify a name converter to use.')->end() @@ -111,7 +112,7 @@ public function getConfigTreeBuilder() ->booleanNode('enable_fos_user')->defaultValue(class_exists(FOSUserBundle::class))->info('Enable the FOSUserBundle integration.')->end() ->booleanNode('enable_nelmio_api_doc') ->defaultFalse() - ->setDeprecated('Enabling the NelmioApiDocBundle integration has been deprecated in 2.2 and will be removed in 3.0. NelmioApiDocBundle 3 has native support for API Platform.') + ->setDeprecated(...$this->buildDeprecationArgs('2.2', 'Enabling the NelmioApiDocBundle integration has been deprecated in 2.2 and will be removed in 3.0. NelmioApiDocBundle 3 has native support for API Platform.')) ->info('Enable the NelmioApiDocBundle integration.') ->end() ->booleanNode('enable_swagger')->defaultTrue()->info('Enable the Swagger documentation and export.')->end() @@ -567,4 +568,11 @@ private function addDefaultsSection(ArrayNodeDefinition $rootNode): void $defaultsNode->children()->variableNode($snakeCased); } } + + private function buildDeprecationArgs(string $version, string $message): array + { + return method_exists(BaseNode::class, 'getDeprecation') + ? ['api-platform/core', $version, $message] + : [$message]; + } } diff --git a/src/Bridge/Symfony/Bundle/Resources/config/api.xml b/src/Bridge/Symfony/Bundle/Resources/config/api.xml index f475bf67a36..71a070c8505 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/api.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/api.xml @@ -18,7 +18,6 @@ - The "%service_id%" service is deprecated since API Platform 2.5. @@ -71,11 +70,9 @@ %api_platform.formats% - The "%service_id%" service is deprecated since API Platform 2.5. - The "%alias_id%" alias is deprecated since API Platform 2.5. @@ -139,13 +136,9 @@ - - The "%service_id%" service is deprecated since API Platform 2.1 and will be removed in 3.0. Use "api_platform.path_segment_name_generator.underscore" instead. - + - - The "%service_id%" service is deprecated since API Platform 2.1 and will be removed in 3.0. Use "api_platform.path_segment_name_generator.dash" instead. - + diff --git a/src/Bridge/Symfony/Bundle/Resources/config/filter.xml b/src/Bridge/Symfony/Bundle/Resources/config/filter.xml index 61fce0c2d38..7a1a087bd3d 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/filter.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/filter.xml @@ -14,7 +14,6 @@ - The "%service_id%" service is deprecated since 2.1 and will be removed in 3.0. Use the "api_platform.filter_locator" service instead. diff --git a/src/Bridge/Symfony/Bundle/Resources/config/nelmio_api_doc.xml b/src/Bridge/Symfony/Bundle/Resources/config/nelmio_api_doc.xml index fdc8d00f79f..50cfdc59d4e 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/nelmio_api_doc.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/nelmio_api_doc.xml @@ -6,8 +6,6 @@ - The "%service_id%" service is deprecated since API Platform 2.2 and will be removed in 3.0. NelmioApiDocBundle 3 has native support for API Platform. - @@ -18,8 +16,6 @@ - The "%service_id%" service is deprecated since API Platform 2.2 and will be removed in 3.0. NelmioApiDocBundle 3 has native support for API Platform. - diff --git a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php index 9daf7bdd61d..dec70e86d7b 100644 --- a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php +++ b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php @@ -1030,6 +1030,11 @@ private function getPartialContainerBuilderProphecy($configuration = null) $containerBuilderProphecy->removeBindings(Argument::type('string'))->will(function () {}); } + $containerBuilderProphecy->getDefinition(Argument::type('string')) + ->willReturn($this->prophesize(Definition::class)->reveal()); + $containerBuilderProphecy->getAlias(Argument::type('string')) + ->willReturn($this->prophesize(Alias::class)->reveal()); + return $containerBuilderProphecy; } @@ -1393,6 +1398,11 @@ private function getBaseContainerBuilderProphecy(array $doctrineIntegrationsToLo $containerBuilderProphecy->getDefinition('api_platform.graphql.subscription.mercure_iri_generator')->willReturn($definitionDummy); $this->childDefinitionProphecy->setPublic(true)->will(function () {}); + $containerBuilderProphecy->getDefinition(Argument::type('string')) + ->willReturn($this->prophesize(Definition::class)->reveal()); + $containerBuilderProphecy->getAlias(Argument::type('string')) + ->willReturn($this->prophesize(Alias::class)->reveal()); + return $containerBuilderProphecy; } From 1674d88b04b84560f8cdb0934790253d05ff921b Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 15 Jun 2020 16:23:34 +0200 Subject: [PATCH 087/160] Fix phpstan issues --- phpstan.neon.dist | 9 +++- .../Doctrine/Orm/Filter/ExistsFilter.php | 8 +++- .../Doctrine/Orm/Filter/OrderFilter.php | 9 +++- src/GraphQl/Action/EntrypointAction.php | 2 +- .../TransformFieldsetsParametersListener.php | 7 ++- .../TransformFilteringParametersListener.php | 8 +++- .../TransformPaginationParametersListener.php | 3 +- .../TransformSortingParametersListener.php | 5 +- src/Test/DoctrineMongoDbOdmFilterTestCase.php | 3 +- src/Test/DoctrineMongoDbOdmTestCase.php | 46 +++++++++++++++++++ ...ansformFieldsetsParametersListenerTest.php | 10 ++++ ...ansformFilteringParametersListenerTest.php | 6 +++ 12 files changed, 104 insertions(+), 12 deletions(-) create mode 100644 src/Test/DoctrineMongoDbOdmTestCase.php diff --git a/phpstan.neon.dist b/phpstan.neon.dist index b2352a8425b..f67b5da1ad3 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -8,7 +8,7 @@ parameters: symfony: container_xml_path: tests/Fixtures/app/var/cache/test/appAppKernelTestDebugContainer.xml constant_hassers: false - autoload_files: + bootstrapFiles: - tests/Fixtures/app/AppKernel.php excludes_analyse: - tests/Fixtures/app/var/cache @@ -132,3 +132,10 @@ parameters: - message: "#Call to function method_exists\\(\\) with ApiPlatform\\\\Core\\\\JsonApi\\\\Serializer\\\\ItemNormalizer and 'setCircularReferenc…' will always evaluate to false\\.#" path: tests/JsonApi/Serializer/ItemNormalizerTest.php + + # Waiting to be fixed by https://github.com/Roave/BetterReflection/issues/663 + - + message: '#Call to private method getNestedFieldPath\(\) of class ApiPlatform\\Core\\Bridge\\Elasticsearch\\DataProvider\\Filter\\AbstractFilter\.#' + paths: + - src/Bridge/Elasticsearch/DataProvider/Filter/OrderFilter.php + - src/Bridge/Elasticsearch/DataProvider/Filter/AbstractSearchFilter.php diff --git a/src/Bridge/Doctrine/Orm/Filter/ExistsFilter.php b/src/Bridge/Doctrine/Orm/Filter/ExistsFilter.php index 3f2cda3a865..f030f5734e0 100644 --- a/src/Bridge/Doctrine/Orm/Filter/ExistsFilter.php +++ b/src/Bridge/Doctrine/Orm/Filter/ExistsFilter.php @@ -22,6 +22,7 @@ use Doctrine\ORM\Query\Expr\Join; use Doctrine\ORM\QueryBuilder; use Psr\Log\LoggerInterface; +use Symfony\Component\HttpFoundation\InputBag; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; @@ -204,8 +205,13 @@ protected function extractProperties(Request $request/*, string $resourceClass*/ @trigger_error(sprintf('The use of "%s::extractProperties()" is deprecated since 2.2. Use the "filters" key of the context instead.', __CLASS__), E_USER_DEPRECATED); - $properties = $request->query->get($this->existsParameterName); + // symfony > 5.1 + if (class_exists(InputBag::class)) { + return $request->query->all($this->existsParameterName); + } + $properties = $request->query->get($this->existsParameterName); + /* @phpstan-ignore-next-line */ return \is_array($properties) ? $properties : []; } } diff --git a/src/Bridge/Doctrine/Orm/Filter/OrderFilter.php b/src/Bridge/Doctrine/Orm/Filter/OrderFilter.php index f019c562676..9d2dba0dd25 100644 --- a/src/Bridge/Doctrine/Orm/Filter/OrderFilter.php +++ b/src/Bridge/Doctrine/Orm/Filter/OrderFilter.php @@ -20,6 +20,7 @@ use Doctrine\ORM\Query\Expr\Join; use Doctrine\ORM\QueryBuilder; use Psr\Log\LoggerInterface; +use Symfony\Component\HttpFoundation\InputBag; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; @@ -120,8 +121,14 @@ protected function filterProperty(string $property, $direction, QueryBuilder $qu protected function extractProperties(Request $request/*, string $resourceClass*/): array { @trigger_error(sprintf('The use of "%s::extractProperties()" is deprecated since 2.2. Use the "filters" key of the context instead.', __CLASS__), E_USER_DEPRECATED); - $properties = $request->query->get($this->orderParameterName); + // symfony > 5.1 + if (class_exists(InputBag::class)) { + return $request->query->all($this->orderParameterName); + } + + $properties = $request->query->get($this->orderParameterName); + /* @phpstan-ignore-next-line */ return \is_array($properties) ? $properties : []; } } diff --git a/src/GraphQl/Action/EntrypointAction.php b/src/GraphQl/Action/EntrypointAction.php index e768918dc21..825fb601ed2 100644 --- a/src/GraphQl/Action/EntrypointAction.php +++ b/src/GraphQl/Action/EntrypointAction.php @@ -92,7 +92,7 @@ private function parseRequest(Request $request): array { $query = $request->query->get('query'); $operationName = $request->query->get('operationName'); - if ($variables = $request->query->get('variables', [])) { + if ($variables = $request->query->get('variables') ?: []) { $variables = $this->decodeVariables($variables); } diff --git a/src/JsonApi/EventListener/TransformFieldsetsParametersListener.php b/src/JsonApi/EventListener/TransformFieldsetsParametersListener.php index ec10a093f1f..98bd3a13db7 100644 --- a/src/JsonApi/EventListener/TransformFieldsetsParametersListener.php +++ b/src/JsonApi/EventListener/TransformFieldsetsParametersListener.php @@ -14,6 +14,7 @@ namespace ApiPlatform\Core\JsonApi\EventListener; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use Symfony\Component\HttpFoundation\InputBag; use Symfony\Component\HttpKernel\Event\RequestEvent; /** @@ -36,17 +37,19 @@ public function onKernelRequest(RequestEvent $event): void $request = $event->getRequest(); $includeParameter = $request->query->get('include'); + $fieldsParameter = class_exists(InputBag::class) ? $request->query->all('fields') : $request->query->get('fields'); + if ( 'jsonapi' !== $request->getRequestFormat() || !($resourceClass = $request->attributes->get('_api_resource_class')) || - (!($fieldsParameter = $request->query->get('fields')) && !$includeParameter) + (!$fieldsParameter && !$includeParameter) ) { return; } if ( ($fieldsParameter && !\is_array($fieldsParameter)) || - ($includeParameter && !\is_string($includeParameter)) + (!\is_string($includeParameter)) ) { return; } diff --git a/src/JsonApi/EventListener/TransformFilteringParametersListener.php b/src/JsonApi/EventListener/TransformFilteringParametersListener.php index d4b36490c47..bebabdba425 100644 --- a/src/JsonApi/EventListener/TransformFilteringParametersListener.php +++ b/src/JsonApi/EventListener/TransformFilteringParametersListener.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Core\JsonApi\EventListener; +use Symfony\Component\HttpFoundation\InputBag; use Symfony\Component\HttpKernel\Event\RequestEvent; /** @@ -27,13 +28,16 @@ final class TransformFilteringParametersListener public function onKernelRequest(RequestEvent $event): void { $request = $event->getRequest(); + $filterParameter = class_exists(InputBag::class) ? $request->query->all('filter') : $request->query->get('filter'); + if ( 'jsonapi' !== $request->getRequestFormat() || - null === ($filterParameter = $request->query->get('filter')) || - !\is_array($filterParameter) + !\is_array($filterParameter) || + !$filterParameter ) { return; } + $filters = $request->attributes->get('_api_filters', []); $request->attributes->set('_api_filters', array_merge($filterParameter, $filters)); } diff --git a/src/JsonApi/EventListener/TransformPaginationParametersListener.php b/src/JsonApi/EventListener/TransformPaginationParametersListener.php index a8d2ae6f59c..e92f8ea186b 100644 --- a/src/JsonApi/EventListener/TransformPaginationParametersListener.php +++ b/src/JsonApi/EventListener/TransformPaginationParametersListener.php @@ -28,9 +28,10 @@ public function onKernelRequest(RequestEvent $event): void { $request = $event->getRequest(); + $pageParameter = class_exists(InputBag::class) ? $request->query->all('page') : $request->query->get('page'); + if ( 'jsonapi' !== $request->getRequestFormat() || - null === ($pageParameter = $request->query->get('page')) || !\is_array($pageParameter) ) { return; diff --git a/src/JsonApi/EventListener/TransformSortingParametersListener.php b/src/JsonApi/EventListener/TransformSortingParametersListener.php index 5a364575571..b47cd3161e3 100644 --- a/src/JsonApi/EventListener/TransformSortingParametersListener.php +++ b/src/JsonApi/EventListener/TransformSortingParametersListener.php @@ -35,9 +35,12 @@ public function onKernelRequest(RequestEvent $event): void { $request = $event->getRequest(); + $orderParameter = $request->query->get('sort'); + if ( 'jsonapi' !== $request->getRequestFormat() || - null === ($orderParameter = $request->query->get('sort')) || + null === $orderParameter || + /* @phpstan-ignore-next-line */ \is_array($orderParameter) ) { return; diff --git a/src/Test/DoctrineMongoDbOdmFilterTestCase.php b/src/Test/DoctrineMongoDbOdmFilterTestCase.php index 653594e59d0..a1299eb04cb 100644 --- a/src/Test/DoctrineMongoDbOdmFilterTestCase.php +++ b/src/Test/DoctrineMongoDbOdmFilterTestCase.php @@ -15,7 +15,6 @@ use ApiPlatform\Core\Bridge\Doctrine\MongoDbOdm\Filter\FilterInterface; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\Dummy; -use Doctrine\Bundle\MongoDBBundle\Tests\TestCase; use Doctrine\Common\Persistence\ManagerRegistry; use Doctrine\ODM\MongoDB\Repository\DocumentRepository; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; @@ -51,7 +50,7 @@ protected function setUp(): void { self::bootKernel(); - $manager = TestCase::createTestDocumentManager(); + $manager = DoctrineMongoDbOdmTestCase::createTestDocumentManager(); $this->managerRegistry = self::$kernel->getContainer()->get('doctrine_mongodb'); $this->repository = $manager->getRepository(Dummy::class); } diff --git a/src/Test/DoctrineMongoDbOdmTestCase.php b/src/Test/DoctrineMongoDbOdmTestCase.php new file mode 100644 index 00000000000..36907e29a27 --- /dev/null +++ b/src/Test/DoctrineMongoDbOdmTestCase.php @@ -0,0 +1,46 @@ + + * + * 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\Test; + +use Doctrine\Common\Annotations\AnnotationReader; +use Doctrine\Common\Cache\ArrayCache; +use Doctrine\ODM\MongoDB\Configuration; +use Doctrine\ODM\MongoDB\DocumentManager; +use Doctrine\ODM\MongoDB\Mapping\Driver\AnnotationDriver; +use PHPUnit\Framework\TestCase; +use function sys_get_temp_dir; + +/** + * Source: https://github.com/doctrine/DoctrineMongoDBBundle/blob/0174003844bc566bb4cb3b7d10c5528d1924d719/Tests/TestCase.php + * Test got excluded from vendor in 4.x. + */ +class DoctrineMongoDbOdmTestCase extends TestCase +{ + /** + * @return DocumentManager + */ + public static function createTestDocumentManager($paths = []) + { + $config = new Configuration(); + $config->setAutoGenerateProxyClasses(Configuration::AUTOGENERATE_FILE_NOT_EXISTS); + $config->setProxyDir(sys_get_temp_dir()); + $config->setHydratorDir(sys_get_temp_dir()); + $config->setProxyNamespace('SymfonyTests\Doctrine'); + $config->setHydratorNamespace('SymfonyTests\Doctrine'); + $config->setMetadataDriverImpl(new AnnotationDriver(new AnnotationReader(), $paths)); + $config->setMetadataCacheImpl(new ArrayCache()); + + return DocumentManager::create(null, $config); + } +} diff --git a/tests/JsonApi/EventListener/TransformFieldsetsParametersListenerTest.php b/tests/JsonApi/EventListener/TransformFieldsetsParametersListenerTest.php index 849af299d43..8e38c6b7b9e 100644 --- a/tests/JsonApi/EventListener/TransformFieldsetsParametersListenerTest.php +++ b/tests/JsonApi/EventListener/TransformFieldsetsParametersListenerTest.php @@ -18,6 +18,8 @@ use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy; use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Exception\BadRequestException; +use Symfony\Component\HttpFoundation\InputBag; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Event\RequestEvent; @@ -50,6 +52,10 @@ public function testOnKernelRequestWithInvalidFormat() public function testOnKernelRequestWithInvalidFilter() { + if (class_exists(InputBag::class)) { + $this->expectException(BadRequestException::class); + } + $eventProphecy = $this->prophesize(RequestEvent::class); $expectedRequest = new Request(); @@ -127,6 +133,10 @@ public function testOnKernelRequestWithIncludeWithoutFields() public function testOnKernelRequestWithWrongParametersTypesDoesnTAffectRequestAttributes() { + if (class_exists(InputBag::class)) { + $this->expectException(BadRequestException::class); + } + $request = new Request( ['fields' => 'foo', 'include' => ['relatedDummy,foo']], [], diff --git a/tests/JsonApi/EventListener/TransformFilteringParametersListenerTest.php b/tests/JsonApi/EventListener/TransformFilteringParametersListenerTest.php index 551362561ff..ff4ee7839eb 100644 --- a/tests/JsonApi/EventListener/TransformFilteringParametersListenerTest.php +++ b/tests/JsonApi/EventListener/TransformFilteringParametersListenerTest.php @@ -15,6 +15,8 @@ use ApiPlatform\Core\JsonApi\EventListener\TransformFilteringParametersListener; use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Exception\BadRequestException; +use Symfony\Component\HttpFoundation\InputBag; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Event\RequestEvent; @@ -47,6 +49,10 @@ public function testOnKernelRequestWithInvalidFormat() public function testOnKernelRequestWithInvalidFilter() { + if (class_exists(InputBag::class)) { + $this->expectException(BadRequestException::class); + } + $eventProphecy = $this->prophesize(RequestEvent::class); $expectedRequest = new Request(); From ff60de751e0f6713eef834c36e0e733bd2341231 Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 15 Jun 2020 17:25:23 +0200 Subject: [PATCH 088/160] fix graphql convert type --- src/GraphQl/Type/TypeConverter.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/GraphQl/Type/TypeConverter.php b/src/GraphQl/Type/TypeConverter.php index 78f6d79e690..d25736b2993 100644 --- a/src/GraphQl/Type/TypeConverter.php +++ b/src/GraphQl/Type/TypeConverter.php @@ -62,7 +62,7 @@ public function convertType(Type $type, bool $input, ?string $queryName, ?string return GraphQLType::string(); case Type::BUILTIN_TYPE_ARRAY: case Type::BUILTIN_TYPE_ITERABLE: - if ($resourceType = $this->getResourceType($type, $input, $queryName, $mutationName, $depth)) { + if ($resourceType = $this->getResourceType($type, $input, $queryName, $mutationName, $subscriptionName, $depth)) { return $resourceType; } From 76cafc213c8b0cfddb1d205551c33434f27799a0 Mon Sep 17 00:00:00 2001 From: Daniel West Date: Fri, 8 May 2020 20:33:49 +0100 Subject: [PATCH 089/160] Remove URL encoding of identifier --- CHANGELOG.md | 1 + features/bootstrap/DoctrineContext.php | 12 +++++ features/main/url_encoded_id.feature | 24 +++++++++ src/Bridge/Symfony/Routing/IriConverter.php | 2 +- .../TestBundle/Entity/UrlEncodedId.php | 51 +++++++++++++++++++ 5 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 features/main/url_encoded_id.feature create mode 100644 tests/Fixtures/TestBundle/Entity/UrlEncodedId.php diff --git a/CHANGELOG.md b/CHANGELOG.md index a748169cbcd..524e0e02c21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ * OpenAPI: Add PHP default values to the documentation (#2386) * Deprecate using a validation groups generator service not implementing `ApiPlatform\Core\Bridge\Symfony\Validator\ValidationGroupsGeneratorInterface` (#3346) * Subresources: subresource resourceClass can now be defined as a container parameter in XML and Yaml definitions +* Url Encoded IRIs: Fix IRI url encoding ## 2.5.6 diff --git a/features/bootstrap/DoctrineContext.php b/features/bootstrap/DoctrineContext.php index f1adf28cc70..3b8b54dbd20 100644 --- a/features/bootstrap/DoctrineContext.php +++ b/features/bootstrap/DoctrineContext.php @@ -129,6 +129,7 @@ use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\SoMany; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Taxon; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\ThirdLevel; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\UrlEncodedId; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\User; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\UuidIdentifierDummy; use Behat\Behat\Context\Context; @@ -1055,6 +1056,17 @@ public function thereIsAnAnswerToTheQuestion(string $a, string $q) $this->manager->clear(); } + /** + * @Given there is a UrlEncodedId resource + */ + public function thereIsAUrlEncodedIdResource() + { + $urlEncodedIdResource = new UrlEncodedId(); + $this->manager->persist($urlEncodedIdResource); + $this->manager->flush(); + $this->manager->clear(); + } + /** * @Then the password :password for user :user should be hashed */ diff --git a/features/main/url_encoded_id.feature b/features/main/url_encoded_id.feature new file mode 100644 index 00000000000..04444634b1a --- /dev/null +++ b/features/main/url_encoded_id.feature @@ -0,0 +1,24 @@ +Feature: Allowing resource identifiers with characters that should be URL encoded + In order to have a resource with an id with special characters + As a client software developer + I need to be able to set and retrieve these resources with the URL encoded ID + + @createSchema + Scenario Outline: Get a resource whether or not the id is URL encoded + Given there is a UrlEncodedId resource + And I add "Content-Type" header equal to "application/ld+json" + When I send a "GET" request to "" + Then the response status code should be 200 + And the JSON should be equal to: + """ + { + "@context": "/contexts/UrlEncodedId", + "@id": "/url_encoded_ids/encode:id", + "@type": "UrlEncodedId", + "id": "encode:id" + } + """ + Examples: + | url | + | /url_encoded_ids/encode:id | + | /url_encoded_ids/encode%3Aid | diff --git a/src/Bridge/Symfony/Routing/IriConverter.php b/src/Bridge/Symfony/Routing/IriConverter.php index 001bb7f3bd9..82ec9bc38f9 100644 --- a/src/Bridge/Symfony/Routing/IriConverter.php +++ b/src/Bridge/Symfony/Routing/IriConverter.php @@ -182,7 +182,7 @@ private function generateIdentifiersUrl(array $identifiers, string $resourceClas } if (1 === \count($identifiers)) { - return [rawurlencode((string) reset($identifiers))]; + return [(string) reset($identifiers)]; } foreach ($identifiers as $name => $value) { diff --git a/tests/Fixtures/TestBundle/Entity/UrlEncodedId.php b/tests/Fixtures/TestBundle/Entity/UrlEncodedId.php new file mode 100644 index 00000000000..23298bf36a1 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/UrlEncodedId.php @@ -0,0 +1,51 @@ + + * + * 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\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Core\Annotation\ApiResource; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Dto\CustomInputDto; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Dto\CustomOutputDto; +use Doctrine\ORM\Mapping as ORM; + +/** + * @author Daniel West + * + * Resource with an ID that will be URL encoded + * + * @ORM\Entity + * + * @ApiResource( + * itemOperations={ + * "get"={ + * "method"="GET", + * "requirements"={"id"=".+"} + * } + * } + * ) + */ +class UrlEncodedId +{ + /** + * @var int The id + * + * @ORM\Column(type="string") + * @ORM\Id + */ + private $id = 'encode:id'; + + public function getId() + { + return $this->id; + } +} From 0eb6ef2f9b637b32d484f8948663e0a251645026 Mon Sep 17 00:00:00 2001 From: Daniel West Date: Fri, 8 May 2020 20:59:34 +0100 Subject: [PATCH 090/160] Clarify use cases of ids with special url encoded characters --- features/main/url_encoded_id.feature | 12 +++++++----- src/JsonLd/Serializer/ItemNormalizer.php | 1 + tests/Fixtures/TestBundle/Entity/UrlEncodedId.php | 2 +- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/features/main/url_encoded_id.feature b/features/main/url_encoded_id.feature index 04444634b1a..bf0190d71e7 100644 --- a/features/main/url_encoded_id.feature +++ b/features/main/url_encoded_id.feature @@ -13,12 +13,14 @@ Feature: Allowing resource identifiers with characters that should be URL encode """ { "@context": "/contexts/UrlEncodedId", - "@id": "/url_encoded_ids/encode:id", + "@id": "/url_encoded_ids/%25encode:id", "@type": "UrlEncodedId", - "id": "encode:id" + "id": "%encode:id" } """ Examples: - | url | - | /url_encoded_ids/encode:id | - | /url_encoded_ids/encode%3Aid | + | url | + | /url_encoded_ids/%encode:id | + | /url_encoded_ids/%25encode%3Aid | + | /url_encoded_ids/%25encode:id | + | /url_encoded_ids/%encode%3Aid | diff --git a/src/JsonLd/Serializer/ItemNormalizer.php b/src/JsonLd/Serializer/ItemNormalizer.php index 793eba67639..451d1b2c478 100644 --- a/src/JsonLd/Serializer/ItemNormalizer.php +++ b/src/JsonLd/Serializer/ItemNormalizer.php @@ -74,6 +74,7 @@ public function normalize($object, $format = null, array $context = []) $resourceClass = $this->resourceClassResolver->getResourceClass($object, $context['resource_class'] ?? null); $context = $this->initContext($resourceClass, $context); + $iri = $this->iriConverter->getIriFromItem($object); $context['iri'] = $iri; $context['api_normalize'] = true; diff --git a/tests/Fixtures/TestBundle/Entity/UrlEncodedId.php b/tests/Fixtures/TestBundle/Entity/UrlEncodedId.php index 23298bf36a1..5d3aa9ad415 100644 --- a/tests/Fixtures/TestBundle/Entity/UrlEncodedId.php +++ b/tests/Fixtures/TestBundle/Entity/UrlEncodedId.php @@ -42,7 +42,7 @@ class UrlEncodedId * @ORM\Column(type="string") * @ORM\Id */ - private $id = 'encode:id'; + private $id = '%encode:id'; public function getId() { From 8a15ed738898d2694e4da032d8b1431797826c41 Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 18 Jun 2020 17:07:13 +0200 Subject: [PATCH 091/160] Style fixes --- CHANGELOG.md | 2 +- src/JsonLd/Serializer/ItemNormalizer.php | 1 - tests/Fixtures/TestBundle/Entity/UrlEncodedId.php | 12 ++++-------- 3 files changed, 5 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 524e0e02c21..61a6a5186ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ * OpenAPI: Add PHP default values to the documentation (#2386) * Deprecate using a validation groups generator service not implementing `ApiPlatform\Core\Bridge\Symfony\Validator\ValidationGroupsGeneratorInterface` (#3346) * Subresources: subresource resourceClass can now be defined as a container parameter in XML and Yaml definitions -* Url Encoded IRIs: Fix IRI url encoding +* IriConverter: Fix IRI url double encoding - may cause breaking change as some characters no longer encoded in output (#3552) ## 2.5.6 diff --git a/src/JsonLd/Serializer/ItemNormalizer.php b/src/JsonLd/Serializer/ItemNormalizer.php index 451d1b2c478..793eba67639 100644 --- a/src/JsonLd/Serializer/ItemNormalizer.php +++ b/src/JsonLd/Serializer/ItemNormalizer.php @@ -74,7 +74,6 @@ public function normalize($object, $format = null, array $context = []) $resourceClass = $this->resourceClassResolver->getResourceClass($object, $context['resource_class'] ?? null); $context = $this->initContext($resourceClass, $context); - $iri = $this->iriConverter->getIriFromItem($object); $context['iri'] = $iri; $context['api_normalize'] = true; diff --git a/tests/Fixtures/TestBundle/Entity/UrlEncodedId.php b/tests/Fixtures/TestBundle/Entity/UrlEncodedId.php index 5d3aa9ad415..6ff5a65da87 100644 --- a/tests/Fixtures/TestBundle/Entity/UrlEncodedId.php +++ b/tests/Fixtures/TestBundle/Entity/UrlEncodedId.php @@ -14,8 +14,6 @@ namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity; use ApiPlatform\Core\Annotation\ApiResource; -use ApiPlatform\Core\Tests\Fixtures\TestBundle\Dto\CustomInputDto; -use ApiPlatform\Core\Tests\Fixtures\TestBundle\Dto\CustomOutputDto; use Doctrine\ORM\Mapping as ORM; /** @@ -27,18 +25,16 @@ * * @ApiResource( * itemOperations={ - * "get"={ - * "method"="GET", - * "requirements"={"id"=".+"} - * } + * "get"={ + * "method"="GET", + * "requirements"={"id"=".+"} + * } * } * ) */ class UrlEncodedId { /** - * @var int The id - * * @ORM\Column(type="string") * @ORM\Id */ From f1e3440da089807d1cf94eb9f8b9e130bd80691c Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 18 Jun 2020 17:33:00 +0200 Subject: [PATCH 092/160] Fix test double-encoded --- features/main/table_inheritance.feature | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/features/main/table_inheritance.feature b/features/main/table_inheritance.feature index 379e39f49e1..85cf6be152f 100644 --- a/features/main/table_inheritance.feature +++ b/features/main/table_inheritance.feature @@ -538,7 +538,7 @@ Feature: Table inheritance }, "@id": { "type": "string", - "pattern": "^/resource_interfaces/single%2520item$" + "pattern": "^/resource_interfaces/single%20item$" }, "@type": { "type": "string", From acfcd139f1c5bbadf4d999aeadd1b996c683939f Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 18 Jun 2020 17:46:38 +0200 Subject: [PATCH 093/160] fix mongodb tests --- features/bootstrap/DoctrineContext.php | 3 +- .../TestBundle/Document/UrlEncodedId.php | 46 +++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 tests/Fixtures/TestBundle/Document/UrlEncodedId.php diff --git a/features/bootstrap/DoctrineContext.php b/features/bootstrap/DoctrineContext.php index 3b8b54dbd20..bfb1ee4cefd 100644 --- a/features/bootstrap/DoctrineContext.php +++ b/features/bootstrap/DoctrineContext.php @@ -67,6 +67,7 @@ use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\SoMany as SoManyDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\Taxon as TaxonDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\ThirdLevel as ThirdLevelDocument; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\UrlEncodedId as UrlEncodedIdDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\User as UserDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Address; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Answer; @@ -1061,7 +1062,7 @@ public function thereIsAnAnswerToTheQuestion(string $a, string $q) */ public function thereIsAUrlEncodedIdResource() { - $urlEncodedIdResource = new UrlEncodedId(); + $urlEncodedIdResource = ($this->isOrm() ? new UrlEncodedId() : new UrlEncodedIdDocument()); $this->manager->persist($urlEncodedIdResource); $this->manager->flush(); $this->manager->clear(); diff --git a/tests/Fixtures/TestBundle/Document/UrlEncodedId.php b/tests/Fixtures/TestBundle/Document/UrlEncodedId.php new file mode 100644 index 00000000000..31ebbf31bb5 --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/UrlEncodedId.php @@ -0,0 +1,46 @@ + + * + * 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\Tests\Fixtures\TestBundle\Document; + +use ApiPlatform\Core\Annotation\ApiResource; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +/** + * @author Daniel West + * + * Resource with an ID that will be URL encoded + * + * @ODM\Document + * + * @ApiResource( + * itemOperations={ + * "get"={ + * "method"="GET", + * "requirements"={"id"=".+"} + * } + * } + * ) + */ +class UrlEncodedId +{ + /** + * @ODM\Id(strategy="none") + */ + private $id = '%encode:id'; + + public function getId() + { + return $this->id; + } +} From e7dad315e01a015c83901698811285b558a2072e Mon Sep 17 00:00:00 2001 From: Alan Poulain Date: Fri, 19 Jun 2020 11:17:49 +0200 Subject: [PATCH 094/160] Use snake case for pagination type (#3614) --- src/DataProvider/Pagination.php | 2 +- tests/Fixtures/TestBundle/Document/FooDummy.php | 2 +- tests/Fixtures/TestBundle/Entity/FooDummy.php | 2 +- tests/GraphQl/Type/FieldsBuilderTest.php | 2 +- tests/GraphQl/Type/TypeBuilderTest.php | 4 ++-- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/DataProvider/Pagination.php b/src/DataProvider/Pagination.php index 6564b955456..ff2c19c2c7a 100644 --- a/src/DataProvider/Pagination.php +++ b/src/DataProvider/Pagination.php @@ -215,7 +215,7 @@ public function getGraphQlPaginationType(string $resourceClass, string $operatio return 'cursor'; } - return (string) $resourceMetadata->getGraphqlAttribute($operationName, 'paginationType', 'cursor', true); + return (string) $resourceMetadata->getGraphqlAttribute($operationName, 'pagination_type', 'cursor', true); } /** diff --git a/tests/Fixtures/TestBundle/Document/FooDummy.php b/tests/Fixtures/TestBundle/Document/FooDummy.php index 90c9de2aec8..e29794ceb44 100644 --- a/tests/Fixtures/TestBundle/Document/FooDummy.php +++ b/tests/Fixtures/TestBundle/Document/FooDummy.php @@ -26,7 +26,7 @@ * "order"={"dummy.name"} * }, * graphql={ - * "collection_query"={"paginationType"="page"} + * "collection_query"={"pagination_type"="page"} * } * ) * @ODM\Document diff --git a/tests/Fixtures/TestBundle/Entity/FooDummy.php b/tests/Fixtures/TestBundle/Entity/FooDummy.php index 1ec0a15a8cb..83bb02a55f2 100644 --- a/tests/Fixtures/TestBundle/Entity/FooDummy.php +++ b/tests/Fixtures/TestBundle/Entity/FooDummy.php @@ -26,7 +26,7 @@ * "order"={"dummy.name"} * }, * graphql={ - * "collection_query"={"paginationType"="page"} + * "collection_query"={"pagination_type"="page"} * } * ) * @ORM\Entity diff --git a/tests/GraphQl/Type/FieldsBuilderTest.php b/tests/GraphQl/Type/FieldsBuilderTest.php index ee4001044db..f460bd3d927 100644 --- a/tests/GraphQl/Type/FieldsBuilderTest.php +++ b/tests/GraphQl/Type/FieldsBuilderTest.php @@ -335,7 +335,7 @@ public function collectionQueryFieldsProvider(): array ], ], ], - 'collection with page-based pagination enabled' => ['resourceClass', (new ResourceMetadata('ShortName', null, null, null, null, ['paginationType' => 'page']))->withGraphql(['action' => ['filters' => ['my_filter']]]), 'action', [], $graphqlType = GraphQLType::listOf(new ObjectType(['name' => 'collection'])), $resolver = function () { + 'collection with page-based pagination enabled' => ['resourceClass', (new ResourceMetadata('ShortName', null, null, null, null, ['pagination_type' => 'page']))->withGraphql(['action' => ['filters' => ['my_filter']]]), 'action', [], $graphqlType = GraphQLType::listOf(new ObjectType(['name' => 'collection'])), $resolver = function () { }, [ 'actionShortNames' => [ diff --git a/tests/GraphQl/Type/TypeBuilderTest.php b/tests/GraphQl/Type/TypeBuilderTest.php index 1721122fcda..7b60708d377 100644 --- a/tests/GraphQl/Type/TypeBuilderTest.php +++ b/tests/GraphQl/Type/TypeBuilderTest.php @@ -436,7 +436,7 @@ public function testCursorBasedGetResourcePaginatedCollectionType(): void null, null, null, - ['paginationType' => 'cursor'] + ['pagination_type' => 'cursor'] )); /** @var ObjectType $resourcePaginatedCollectionType */ @@ -498,7 +498,7 @@ public function testPageBasedGetResourcePaginatedCollectionType(): void null, null, null, - ['paginationType' => 'page'] + ['pagination_type' => 'page'] )); /** @var ObjectType $resourcePaginatedCollectionType */ From 51573a3167f09242e8d05f388df773133b59377a Mon Sep 17 00:00:00 2001 From: Anto Date: Wed, 24 Jun 2020 16:32:52 +0200 Subject: [PATCH 095/160] Add possibility to specify a url generation strategy (#3198) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * add ability to return absolute urls instead of relative IRIs apply absolute url to all generated URLs/IRIs Allow passing an url generation strategy instead of a bool Switch to a resource attribute to manage URL generation strategy * fix: rename function Co-authored-by: Michael A. Bos Co-authored-by: Kévin Dunglas --- features/bootstrap/DoctrineContext.php | 78 +++++++++++- features/doctrine/search_filter.feature | 1 - features/hal/absolute_url.feature | 119 +++++++++++++++++ features/hal/network_path.feature | 117 +++++++++++++++++ features/jsonapi/absolute_url.feature | 120 ++++++++++++++++++ features/jsonapi/network_path.feature | 120 ++++++++++++++++++ features/jsonld/absolute_url.feature | 83 ++++++++++++ features/jsonld/network_path.feature | 86 +++++++++++++ src/Annotation/ApiResource.php | 11 +- .../ApiPlatformExtension.php | 2 + .../DependencyInjection/Configuration.php | 2 +- .../Symfony/Bundle/Resources/config/api.xml | 2 + .../Symfony/Bundle/Resources/config/hal.xml | 1 + .../Bundle/Resources/config/jsonapi.xml | 1 + src/Bridge/Symfony/Routing/IriConverter.php | 31 +++-- src/Bridge/Symfony/Routing/Router.php | 8 +- src/Hal/Serializer/CollectionNormalizer.php | 22 +++- .../Serializer/CollectionNormalizer.php | 22 +++- src/JsonLd/ContextBuilder.php | 9 +- .../AbstractCollectionNormalizer.php | 5 +- src/Util/IriHelper.php | 20 +-- tests/Annotation/ApiResourceTest.php | 3 + .../ApiPlatformExtensionTest.php | 1 + .../Symfony/Routing/IriConverterTest.php | 50 ++++++-- tests/Bridge/Symfony/Routing/RouterTest.php | 19 +++ .../TestBundle/Document/AbsoluteUrlDummy.php | 40 ++++++ .../Document/AbsoluteUrlRelationDummy.php | 48 +++++++ .../TestBundle/Document/NetworkPathDummy.php | 40 ++++++ .../Document/NetworkPathRelationDummy.php | 48 +++++++ .../TestBundle/Entity/AbsoluteUrlDummy.php | 42 ++++++ .../Entity/AbsoluteUrlRelationDummy.php | 50 ++++++++ .../TestBundle/Entity/NetworkPathDummy.php | 42 ++++++ .../Entity/NetworkPathRelationDummy.php | 50 ++++++++ .../Serializer/CollectionNormalizerTest.php | 13 +- .../Serializer/CollectionNormalizerTest.php | 40 +++++- tests/Util/IriHelperTest.php | 58 ++++++++- 36 files changed, 1344 insertions(+), 60 deletions(-) create mode 100644 features/hal/absolute_url.feature create mode 100644 features/hal/network_path.feature create mode 100644 features/jsonapi/absolute_url.feature create mode 100644 features/jsonapi/network_path.feature create mode 100644 features/jsonld/absolute_url.feature create mode 100644 features/jsonld/network_path.feature create mode 100644 tests/Fixtures/TestBundle/Document/AbsoluteUrlDummy.php create mode 100644 tests/Fixtures/TestBundle/Document/AbsoluteUrlRelationDummy.php create mode 100644 tests/Fixtures/TestBundle/Document/NetworkPathDummy.php create mode 100644 tests/Fixtures/TestBundle/Document/NetworkPathRelationDummy.php create mode 100644 tests/Fixtures/TestBundle/Entity/AbsoluteUrlDummy.php create mode 100644 tests/Fixtures/TestBundle/Entity/AbsoluteUrlRelationDummy.php create mode 100644 tests/Fixtures/TestBundle/Entity/NetworkPathDummy.php create mode 100644 tests/Fixtures/TestBundle/Entity/NetworkPathRelationDummy.php diff --git a/features/bootstrap/DoctrineContext.php b/features/bootstrap/DoctrineContext.php index bfb1ee4cefd..a8610a3a500 100644 --- a/features/bootstrap/DoctrineContext.php +++ b/features/bootstrap/DoctrineContext.php @@ -11,6 +11,8 @@ declare(strict_types=1); +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\AbsoluteUrlDummy as AbsoluteUrlDummyDocument; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\AbsoluteUrlRelationDummy as AbsoluteUrlRelationDummyDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\Address as AddressDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\Answer as AnswerDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\CompositeItem as CompositeItemDocument; @@ -52,6 +54,8 @@ use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\FourthLevel as FourthLevelDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\Greeting as GreetingDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\MaxDepthDummy as MaxDepthDummyDocument; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\NetworkPathDummy as NetworkPathDummyDocument; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\NetworkPathRelationDummy as NetworkPathRelationDummyDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\Order as OrderDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\Person as PersonDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\PersonToPet as PersonToPetDocument; @@ -69,6 +73,8 @@ use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\ThirdLevel as ThirdLevelDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\UrlEncodedId as UrlEncodedIdDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\User as UserDocument; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\AbsoluteUrlDummy; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\AbsoluteUrlRelationDummy; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Address; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Answer; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\CompositeItem; @@ -113,6 +119,9 @@ use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Greeting; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\InternalUser; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\MaxDepthDummy; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\NetworkPathDummy; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\NetworkPathRelationDummy; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Node; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Order; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Person; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\PersonToPet; @@ -1533,7 +1542,7 @@ public function thereAreDummyMercureObjects(int $nb) { for ($i = 1; $i <= $nb; ++$i) { $relatedDummy = $this->buildRelatedDummy(); - $relatedDummy->setName('RelatedDummy #'.$i); + $relatedDummy->setName('RelatedDummy #' . $i); $dummyMercure = $this->buildDummyMercure(); $dummyMercure->name = "Dummy Mercure #$i"; @@ -1547,6 +1556,40 @@ public function thereAreDummyMercureObjects(int $nb) $this->manager->flush(); } + /** + * @Given there are :nb absoluteUrlDummy objects with a related absoluteUrlRelationDummy + */ + public function thereAreAbsoluteUrlDummies(int $nb) + { + for ($i = 1; $i <= $nb; ++$i) { + $absoluteUrlRelationDummy = $this->buildAbsoluteUrlRelationDummy(); + $absoluteUrlDummy = $this->buildAbsoluteUrlDummy(); + $absoluteUrlDummy->absoluteUrlRelationDummy = $absoluteUrlRelationDummy; + + $this->manager->persist($absoluteUrlRelationDummy); + $this->manager->persist($absoluteUrlDummy); + } + + $this->manager->flush(); + } + + /** + * @Given there are :nb networkPathDummy objects with a related networkPathRelationDummy + */ + public function thereAreNetworkPathDummies(int $nb) + { + for ($i = 1; $i <= $nb; ++$i) { + $networkPathRelationDummy = $this->buildNetworkPathRelationDummy(); + $networkPathDummy = $this->buildNetworkPathDummy(); + $networkPathDummy->networkPathRelationDummy = $networkPathRelationDummy; + + $this->manager->persist($networkPathRelationDummy); + $this->manager->persist($networkPathDummy); + } + + $this->manager->flush(); + } + private function isOrm(): bool { return null !== $this->schemaTool; @@ -1931,5 +1974,38 @@ private function buildConvertedRelated() private function buildDummyMercure() { return $this->isOrm() ? new DummyMercure() : new DummyMercureDocument(); + + } + + /** + * @return AbsoluteUrlDummyDocument|AbsoluteUrlDummy + */ + private function buildAbsoluteUrlDummy() + { + return $this->isOrm() ? new AbsoluteUrlDummy() : new AbsoluteUrlDummyDocument(); + } + + /** + * @return AbsoluteUrlRelationDummyDocument|AbsoluteUrlRelationDummy + */ + private function buildAbsoluteUrlRelationDummy() + { + return $this->isOrm() ? new AbsoluteUrlRelationDummy() : new AbsoluteUrlRelationDummyDocument(); + } + + /** + * @return NetworkPathDummyDocument|NetworkPathDummy + */ + private function buildNetworkPathDummy() + { + return $this->isOrm() ? new NetworkPathDummy() : new NetworkPathDummyDocument(); + } + + /** + * @return NetworkPathRelationDummyDocument|NetworkPathRelationDummy + */ + private function buildNetworkPathRelationDummy() + { + return $this->isOrm() ? new NetworkPathRelationDummy() : new NetworkPathRelationDummyDocument(); } } diff --git a/features/doctrine/search_filter.feature b/features/doctrine/search_filter.feature index e33b7515c47..b895df1a12a 100644 --- a/features/doctrine/search_filter.feature +++ b/features/doctrine/search_filter.feature @@ -805,7 +805,6 @@ Feature: Search filter on collections When I send a "GET" request to "/converted_owners?name_converted.name_converted=Converted 3" Then the response status code should be 200 And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - Then print last JSON response And the JSON should be valid according to this schema: """ { diff --git a/features/hal/absolute_url.feature b/features/hal/absolute_url.feature new file mode 100644 index 00000000000..394bb425c10 --- /dev/null +++ b/features/hal/absolute_url.feature @@ -0,0 +1,119 @@ +Feature: IRI should contain Absolute URL + In order to add detail to IRIs + Include the absolute url + + @createSchema + Scenario: I should be able to GET a collection of Objects with Absolute Urls + Given there are 1 absoluteUrlDummy objects with a related absoluteUrlRelationDummy + And I add "Accept" header equal to "application/hal+json" + And I send a "GET" request to "/absolute_url_dummies" + And the JSON should be equal to: + """ + { + "_links": { + "self": { + "href": "http://example.com/absolute_url_dummies" + }, + "item": [ + { + "href": "http://example.com/absolute_url_dummies/1" + } + ] + }, + "totalItems": 1, + "itemsPerPage": 3, + "_embedded": { + "item": [ + { + "_links": { + "self": { + "href": "http://example.com/absolute_url_dummies/1" + }, + "absoluteUrlRelationDummy": { + "href": "http://example.com/absolute_url_relation_dummies/1" + } + }, + "id": 1 + } + ] + } + } + """ + + Scenario: I should be able to POST an object using an Absolute Url + Given I add "Accept" header equal to "application/hal+json" + And I add "Content-Type" header equal to "application/json" + And I send a "POST" request to "/absolute_url_relation_dummies" with body: + """ + { + "absolute_url_dummies": "http://example.com/absolute_url_dummies/1" + } + """ + Then the response status code should be 201 + And the JSON should be equal to: + """ + { + "_links": { + "self": { + "href": "http://example.com/absolute_url_relation_dummies/2" + } + }, + "id": 2 + } + """ + + Scenario: I should be able to GET an Item with Absolute Urls + Given I add "Accept" header equal to "application/hal+json" + And I add "Content-Type" header equal to "application/json" + And I send a "GET" request to "/absolute_url_dummies/1" + And the JSON should be equal to: + """ + { + "_links": { + "self": { + "href": "http://example.com/absolute_url_dummies/1" + }, + "absoluteUrlRelationDummy": { + "href": "http://example.com/absolute_url_relation_dummies/1" + } + }, + "id": 1 + } + """ + + Scenario: I should be able to GET subresources with Absolute Urls + Given I add "Accept" header equal to "application/hal+json" + And I add "Content-Type" header equal to "application/json" + And I send a "GET" request to "/absolute_url_relation_dummies/1/absolute_url_dummies" + And the JSON should be equal to: + """ + { + "_links": { + "self": { + "href": "http://example.com/absolute_url_relation_dummies/1/absolute_url_dummies" + }, + "item": [ + { + "href": "http://example.com/absolute_url_dummies/1" + } + ] + }, + "totalItems": 1, + "itemsPerPage": 3, + "_embedded": { + "item": [ + { + "_links": { + "self": { + "href": "http://example.com/absolute_url_dummies/1" + }, + "absoluteUrlRelationDummy": { + "href": "http://example.com/absolute_url_relation_dummies/1" + } + }, + "id": 1 + } + ] + } + } + """ diff --git a/features/hal/network_path.feature b/features/hal/network_path.feature new file mode 100644 index 00000000000..5fba0bcbb21 --- /dev/null +++ b/features/hal/network_path.feature @@ -0,0 +1,117 @@ +Feature: IRI should contain network path + In order to add detail to IRIs + Include the network path + + @createSchema + Scenario: I should be able to GET a collection of objects with network paths + Given there are 1 networkPathDummy objects with a related networkPathRelationDummy + And I add "Accept" header equal to "application/hal+json" + And I send a "GET" request to "/network_path_dummies" + And the JSON should be equal to: + """ + { + "_links": { + "self": { + "href": "//example.com/network_path_dummies" + }, + "item": [ + { + "href": "//example.com/network_path_dummies/1" + } + ] + }, + "totalItems": 1, + "itemsPerPage": 3, + "_embedded": { + "item": [ + { + "_links": { + "self": { + "href": "//example.com/network_path_dummies/1" + }, + "networkPathRelationDummy": { + "href": "//example.com/network_path_relation_dummies/1" + } + }, + "id": 1 + } + ] + } + } + """ + + Scenario: I should be able to POST an object using a network path + Given I add "Accept" header equal to "application/hal+json" + And I add "Content-Type" header equal to "application/json" + And I send a "POST" request to "/network_path_relation_dummies" with body: + """ + { + "network_path_dummies": "//example.com/network_path_dummies/1" + } + """ + Then the response status code should be 201 + And the JSON should be equal to: + """ + { + "_links": { + "self": { + "href": "//example.com/network_path_relation_dummies/2" + } + }, + "id": 2 + } + """ + + Scenario: I should be able to GET an Item with network paths + Given I add "Accept" header equal to "application/hal+json" + And I send a "GET" request to "/network_path_dummies/1" + And the JSON should be equal to: + """ + { + "_links": { + "self": { + "href": "//example.com/network_path_dummies/1" + }, + "networkPathRelationDummy": { + "href": "//example.com/network_path_relation_dummies/1" + } + }, + "id": 1 + } + """ + + Scenario: I should be able to GET subresources with network paths + Given I add "Accept" header equal to "application/hal+json" + And I send a "GET" request to "/network_path_relation_dummies/1/network_path_dummies" + And the JSON should be equal to: + """ + { + "_links": { + "self": { + "href": "//example.com/network_path_relation_dummies/1/network_path_dummies" + }, + "item": [ + { + "href": "//example.com/network_path_dummies/1" + } + ] + }, + "totalItems": 1, + "itemsPerPage": 3, + "_embedded": { + "item": [ + { + "_links": { + "self": { + "href": "//example.com/network_path_dummies/1" + }, + "networkPathRelationDummy": { + "href": "//example.com/network_path_relation_dummies/1" + } + }, + "id": 1 + } + ] + } + } + """ diff --git a/features/jsonapi/absolute_url.feature b/features/jsonapi/absolute_url.feature new file mode 100644 index 00000000000..2bf4d2f0367 --- /dev/null +++ b/features/jsonapi/absolute_url.feature @@ -0,0 +1,120 @@ +Feature: IRI should contain Absolute URL + In order to add detail to IRIs + Include the absolute url + + @createSchema + Scenario: I should be able to GET a collection of Objects with Absolute Urls + Given there are 1 absoluteUrlDummy objects with a related absoluteUrlRelationDummy + And I add "Accept" header equal to "application/vnd.api+json" + And I send a "GET" request to "/absolute_url_dummies" + And the JSON should be equal to: + """ + { + "links": { + "self": "http://example.com/absolute_url_dummies" + }, + "meta": { + "totalItems": 1, + "itemsPerPage": 3, + "currentPage": 1 + }, + "data": [ + { + "id": "http://example.com/absolute_url_dummies/1", + "type": "AbsoluteUrlDummy", + "attributes": { + "_id": 1 + }, + "relationships": { + "absoluteUrlRelationDummy": { + "data": { + "type": "AbsoluteUrlRelationDummy", + "id": "http://example.com/absolute_url_relation_dummies/1" + } + } + } + } + ] + } + """ + + Scenario: I should be able to POST an object using an Absolute Url + Given I add "Accept" header equal to "application/vnd.api+json" + And I add "Content-Type" header equal to "application/json" + And I send a "POST" request to "/absolute_url_relation_dummies" with body: + """ + { + "absolute_url_dummies": "http://example.com/absolute_url_dummies/1" + } + """ + Then the response status code should be 201 + And the JSON should be equal to: + """ + { + "data": { + "id": "http://example.com/absolute_url_relation_dummies/2", + "type": "AbsoluteUrlRelationDummy", + "attributes": { + "_id": 2 + } + } + } + """ + + Scenario: I should be able to GET an Item with Absolute Urls + Given I add "Accept" header equal to "application/vnd.api+json" + And I send a "GET" request to "/absolute_url_dummies/1" + And the JSON should be equal to: + """ + { + "data": { + "id": "http://example.com/absolute_url_dummies/1", + "type": "AbsoluteUrlDummy", + "attributes": { + "_id": 1 + }, + "relationships": { + "absoluteUrlRelationDummy": { + "data": { + "type": "AbsoluteUrlRelationDummy", + "id": "http://example.com/absolute_url_relation_dummies/1" + } + } + } + } + } + """ + + Scenario: I should be able to GET subresources with Absolute Urls + Given I add "Accept" header equal to "application/vnd.api+json" + And I send a "GET" request to "/absolute_url_relation_dummies/1/absolute_url_dummies" + And the JSON should be equal to: + """ + { + "links": { + "self": "http://example.com/absolute_url_relation_dummies/1/absolute_url_dummies" + }, + "meta": { + "totalItems": 1, + "itemsPerPage": 3, + "currentPage": 1 + }, + "data": [ + { + "id": "http://example.com/absolute_url_dummies/1", + "type": "AbsoluteUrlDummy", + "attributes": { + "_id": 1 + }, + "relationships": { + "absoluteUrlRelationDummy": { + "data": { + "type": "AbsoluteUrlRelationDummy", + "id": "http://example.com/absolute_url_relation_dummies/1" + } + } + } + } + ] + } + """ diff --git a/features/jsonapi/network_path.feature b/features/jsonapi/network_path.feature new file mode 100644 index 00000000000..9837fb065e4 --- /dev/null +++ b/features/jsonapi/network_path.feature @@ -0,0 +1,120 @@ +Feature: IRI should contain network path + In order to add detail to IRIs + Include the network path + + @createSchema + Scenario: I should be able to GET a collection of objects with network paths + Given there are 1 networkPathDummy objects with a related networkPathRelationDummy + And I add "Accept" header equal to "application/vnd.api+json" + And I send a "GET" request to "/network_path_dummies" + And the JSON should be equal to: + """ + { + "links": { + "self": "//example.com/network_path_dummies" + }, + "meta": { + "totalItems": 1, + "itemsPerPage": 3, + "currentPage": 1 + }, + "data": [ + { + "id": "//example.com/network_path_dummies/1", + "type": "NetworkPathDummy", + "attributes": { + "_id": 1 + }, + "relationships": { + "networkPathRelationDummy": { + "data": { + "type": "NetworkPathRelationDummy", + "id": "//example.com/network_path_relation_dummies/1" + } + } + } + } + ] + } + """ + + Scenario: I should be able to POST an object using a network path + Given I add "Accept" header equal to "application/vnd.api+json" + And I add "Content-Type" header equal to "application/json" + And I send a "POST" request to "/network_path_relation_dummies" with body: + """ + { + "network_path_dummies": "//example.com/network_path_dummies/1" + } + """ + Then the response status code should be 201 + And the JSON should be equal to: + """ + { + "data": { + "id": "//example.com/network_path_relation_dummies/2", + "type": "NetworkPathRelationDummy", + "attributes": { + "_id": 2 + } + } + } + """ + + Scenario: I should be able to GET an Item with network paths + Given I add "Accept" header equal to "application/vnd.api+json" + And I send a "GET" request to "/network_path_dummies/1" + And the JSON should be equal to: + """ + { + "data": { + "id": "//example.com/network_path_dummies/1", + "type": "NetworkPathDummy", + "attributes": { + "_id": 1 + }, + "relationships": { + "networkPathRelationDummy": { + "data": { + "type": "NetworkPathRelationDummy", + "id": "//example.com/network_path_relation_dummies/1" + } + } + } + } + } + """ + + Scenario: I should be able to GET subresources with network paths + Given I add "Accept" header equal to "application/vnd.api+json" + And I send a "GET" request to "/network_path_relation_dummies/1/network_path_dummies" + And the JSON should be equal to: + """ + { + "links": { + "self": "//example.com/network_path_relation_dummies/1/network_path_dummies" + }, + "meta": { + "totalItems": 1, + "itemsPerPage": 3, + "currentPage": 1 + }, + "data": [ + { + "id": "//example.com/network_path_dummies/1", + "type": "NetworkPathDummy", + "attributes": { + "_id": 1 + }, + "relationships": { + "networkPathRelationDummy": { + "data": { + "type": "NetworkPathRelationDummy", + "id": "//example.com/network_path_relation_dummies/1" + } + } + } + } + ] + } + """ diff --git a/features/jsonld/absolute_url.feature b/features/jsonld/absolute_url.feature new file mode 100644 index 00000000000..4421c7702da --- /dev/null +++ b/features/jsonld/absolute_url.feature @@ -0,0 +1,83 @@ +Feature: IRI should contain Absolute URL + In order to add detail to IRIs + Include the absolute url + + @createSchema + Scenario: I should be able to GET a collection of Objects with Absolute Urls + Given there are 1 absoluteUrlDummy objects with a related absoluteUrlRelationDummy + And I add "Accept" header equal to "application/ld+json" + And I send a "GET" request to "/absolute_url_dummies" + And the JSON should be equal to: + """ + { + "@context": "http://example.com/contexts/AbsoluteUrlDummy", + "@id": "http://example.com/absolute_url_dummies", + "@type": "hydra:Collection", + "hydra:member": [ + { + "@id": "http://example.com/absolute_url_dummies/1", + "@type": "AbsoluteUrlDummy", + "absoluteUrlRelationDummy": "http://example.com/absolute_url_relation_dummies/1", + "id": 1 + } + ], + "hydra:totalItems": 1 + } + + """ + + Scenario: I should be able to POST an object using an Absolute Url + Given I add "Accept" header equal to "application/ld+json" + And I add "Content-Type" header equal to "application/json" + And I send a "POST" request to "/absolute_url_relation_dummies" with body: + """ + { + "absolute_url_dummies": "http://example.com/absolute_url_dummies/1" + } + """ + Then the response status code should be 201 + And the JSON should be equal to: + """ + { + "@context": "http://example.com/contexts/AbsoluteUrlRelationDummy", + "@id": "http://example.com/absolute_url_relation_dummies/2", + "@type": "AbsoluteUrlRelationDummy", + "absoluteUrlDummies": [], + "id": 2 + } + """ + + Scenario: I should be able to GET an Item with Absolute Urls + Given I add "Accept" header equal to "application/ld+json" + And I send a "GET" request to "/absolute_url_dummies/1" + And the JSON should be equal to: + """ + { + "@context": "http://example.com/contexts/AbsoluteUrlDummy", + "@id": "http://example.com/absolute_url_dummies/1", + "@type": "AbsoluteUrlDummy", + "absoluteUrlRelationDummy": "http://example.com/absolute_url_relation_dummies/1", + "id": 1 + } + """ + + Scenario: I should be able to GET subresources with Absolute Urls + Given I add "Accept" header equal to "application/ld+json" + And I send a "GET" request to "/absolute_url_relation_dummies/1/absolute_url_dummies" + And the JSON should be equal to: + """ + { + "@context": "http://example.com/contexts/AbsoluteUrlDummy", + "@id": "http://example.com/absolute_url_relation_dummies/1/absolute_url_dummies", + "@type": "hydra:Collection", + "hydra:member": [ + { + "@id": "http://example.com/absolute_url_dummies/1", + "@type": "AbsoluteUrlDummy", + "absoluteUrlRelationDummy": "http://example.com/absolute_url_relation_dummies/1", + "id": 1 + } + ], + "hydra:totalItems": 1 + } + """ diff --git a/features/jsonld/network_path.feature b/features/jsonld/network_path.feature new file mode 100644 index 00000000000..2f77e39aa91 --- /dev/null +++ b/features/jsonld/network_path.feature @@ -0,0 +1,86 @@ +Feature: IRI should contain network path + In order to add detail to IRIs + Include the network path + + @createSchema + Scenario: I should be able to GET a collection of objects with network paths + Given there are 1 networkPathDummy objects with a related networkPathRelationDummy + And I add "Accept" header equal to "application/ld+json" + And I add "Content-Type" header equal to "application/json" + And I send a "GET" request to "/network_path_dummies" + And the JSON should be equal to: + """ + { + "@context": "//example.com/contexts/NetworkPathDummy", + "@id": "//example.com/network_path_dummies", + "@type": "hydra:Collection", + "hydra:member": [ + { + "@id": "//example.com/network_path_dummies/1", + "@type": "NetworkPathDummy", + "networkPathRelationDummy": "//example.com/network_path_relation_dummies/1", + "id": 1 + } + ], + "hydra:totalItems": 1 + } + + """ + + Scenario: I should be able to POST an object using a network path + Given I add "Accept" header equal to "application/ld+json" + And I add "Content-Type" header equal to "application/json" + And I send a "POST" request to "/network_path_relation_dummies" with body: + """ + { + "network_path_dummies": "//example.com/network_path_dummies/1" + } + """ + Then the response status code should be 201 + And the JSON should be equal to: + """ + { + "@context": "//example.com/contexts/NetworkPathRelationDummy", + "@id": "//example.com/network_path_relation_dummies/2", + "@type": "NetworkPathRelationDummy", + "networkPathDummies": [], + "id": 2 + } + """ + + Scenario: I should be able to GET an Item with network paths + Given I add "Accept" header equal to "application/ld+json" + And I add "Content-Type" header equal to "application/json" + And I send a "GET" request to "/network_path_dummies/1" + And the JSON should be equal to: + """ + { + "@context": "//example.com/contexts/NetworkPathDummy", + "@id": "//example.com/network_path_dummies/1", + "@type": "NetworkPathDummy", + "networkPathRelationDummy": "//example.com/network_path_relation_dummies/1", + "id": 1 + } + """ + + Scenario: I should be able to GET subresources with network paths + Given I add "Accept" header equal to "application/ld+json" + And I add "Content-Type" header equal to "application/json" + And I send a "GET" request to "/network_path_relation_dummies/1/network_path_dummies" + And the JSON should be equal to: + """ + { + "@context": "//example.com/contexts/NetworkPathDummy", + "@id": "//example.com/network_path_relation_dummies/1/network_path_dummies", + "@type": "hydra:Collection", + "hydra:member": [ + { + "@id": "//example.com/network_path_dummies/1", + "@type": "NetworkPathDummy", + "networkPathRelationDummy": "//example.com/network_path_relation_dummies/1", + "id": 1 + } + ], + "hydra:totalItems": 1 + } + """ diff --git a/src/Annotation/ApiResource.php b/src/Annotation/ApiResource.php index 1d78c6a26fd..1a89a559ec7 100644 --- a/src/Annotation/ApiResource.php +++ b/src/Annotation/ApiResource.php @@ -66,7 +66,8 @@ * @Attribute("subresourceOperations", type="array"), * @Attribute("sunset", type="string"), * @Attribute("swaggerContext", type="array"), - * @Attribute("validationGroups", type="mixed") + * @Attribute("urlGenerationStrategy", type="int"), + * @Attribute("validationGroups", type="mixed"), * ) */ final class ApiResource @@ -119,6 +120,7 @@ final class ApiResource 'routePrefix', 'sunset', 'swaggerContext', + 'urlGenerationStrategy', 'validationGroups', ]; @@ -434,6 +436,13 @@ final class ApiResource */ private $validationGroups; + /** + * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 + * + * @var int + */ + private $urlGenerationStrategy; + /** * @throws InvalidArgumentException */ diff --git a/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index bf48df38a3b..0b1db78d2c8 100644 --- a/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -14,6 +14,7 @@ namespace ApiPlatform\Core\Bridge\Symfony\Bundle\DependencyInjection; use ApiPlatform\Core\Api\FilterInterface; +use ApiPlatform\Core\Api\UrlGeneratorInterface; use ApiPlatform\Core\Bridge\Doctrine\MongoDbOdm\Extension\AggregationCollectionExtensionInterface; use ApiPlatform\Core\Bridge\Doctrine\MongoDbOdm\Extension\AggregationItemExtensionInterface; use ApiPlatform\Core\Bridge\Doctrine\MongoDbOdm\Filter\AbstractFilter as DoctrineMongoDbOdmAbstractFilter; @@ -171,6 +172,7 @@ private function registerCommonConfiguration(ContainerBuilder $container, array $container->setParameter('api_platform.description', $config['description']); $container->setParameter('api_platform.version', $config['version']); $container->setParameter('api_platform.show_webby', $config['show_webby']); + $container->setParameter('api_platform.url_generation_strategy', $config['defaults']['url_generation_strategy'] ?? UrlGeneratorInterface::ABS_PATH); $container->setParameter('api_platform.exception_to_status', $config['exception_to_status']); $container->setParameter('api_platform.formats', $formats); $container->setParameter('api_platform.patch_formats', $patchFormats); diff --git a/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php b/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php index d740813c86c..536d15ad886 100644 --- a/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php +++ b/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php @@ -553,7 +553,7 @@ private function addDefaultsSection(ArrayNodeDefinition $rootNode): void $defaultsNode ->ignoreExtraKeys() ->beforeNormalization() - ->always(function (array $defaults) use ($nameConverter) { + ->always(static function (array $defaults) use ($nameConverter) { $normalizedDefaults = []; foreach ($defaults as $option => $value) { $option = $nameConverter->normalize($option); diff --git a/src/Bridge/Symfony/Bundle/Resources/config/api.xml b/src/Bridge/Symfony/Bundle/Resources/config/api.xml index 71a070c8505..ffa00199f6d 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/api.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/api.xml @@ -51,6 +51,7 @@ + %api_platform.url_generation_strategy% @@ -64,6 +65,7 @@ + diff --git a/src/Bridge/Symfony/Bundle/Resources/config/hal.xml b/src/Bridge/Symfony/Bundle/Resources/config/hal.xml index a42c976c90b..9ba48f25be3 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/hal.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/hal.xml @@ -22,6 +22,7 @@ %api_platform.collection.pagination.page_parameter_name% + diff --git a/src/Bridge/Symfony/Bundle/Resources/config/jsonapi.xml b/src/Bridge/Symfony/Bundle/Resources/config/jsonapi.xml index 7d232706bc1..858b30e30d9 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/jsonapi.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/jsonapi.xml @@ -26,6 +26,7 @@ %api_platform.collection.pagination.page_parameter_name% + diff --git a/src/Bridge/Symfony/Routing/IriConverter.php b/src/Bridge/Symfony/Routing/IriConverter.php index 82ec9bc38f9..1e031609f8a 100644 --- a/src/Bridge/Symfony/Routing/IriConverter.php +++ b/src/Bridge/Symfony/Routing/IriConverter.php @@ -18,7 +18,6 @@ use ApiPlatform\Core\Api\IriConverterInterface; use ApiPlatform\Core\Api\OperationType; use ApiPlatform\Core\Api\ResourceClassResolverInterface; -use ApiPlatform\Core\Api\UrlGeneratorInterface; use ApiPlatform\Core\DataProvider\ItemDataProviderInterface; use ApiPlatform\Core\DataProvider\OperationDataProviderTrait; use ApiPlatform\Core\DataProvider\SubresourceDataProviderInterface; @@ -29,6 +28,7 @@ use ApiPlatform\Core\Identifier\IdentifierConverterInterface; use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use ApiPlatform\Core\Util\AttributesExtractor; use ApiPlatform\Core\Util\ResourceClassInfoTrait; use Symfony\Component\PropertyAccess\PropertyAccess; @@ -50,7 +50,7 @@ final class IriConverter implements IriConverterInterface private $router; private $identifiersExtractor; - public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, ItemDataProviderInterface $itemDataProvider, RouteNameResolverInterface $routeNameResolver, RouterInterface $router, PropertyAccessorInterface $propertyAccessor = null, IdentifiersExtractorInterface $identifiersExtractor = null, SubresourceDataProviderInterface $subresourceDataProvider = null, IdentifierConverterInterface $identifierConverter = null, ResourceClassResolverInterface $resourceClassResolver = null) + public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, ItemDataProviderInterface $itemDataProvider, RouteNameResolverInterface $routeNameResolver, RouterInterface $router, PropertyAccessorInterface $propertyAccessor = null, IdentifiersExtractorInterface $identifiersExtractor = null, SubresourceDataProviderInterface $subresourceDataProvider = null, IdentifierConverterInterface $identifierConverter = null, ResourceClassResolverInterface $resourceClassResolver = null, ResourceMetadataFactoryInterface $resourceMetadataFactory = null) { $this->itemDataProvider = $itemDataProvider; $this->routeNameResolver = $routeNameResolver; @@ -64,6 +64,7 @@ public function __construct(PropertyNameCollectionFactoryInterface $propertyName @trigger_error(sprintf('Not injecting "%s" is deprecated since API Platform 2.1 and will not be possible anymore in API Platform 3', IdentifiersExtractorInterface::class), E_USER_DEPRECATED); $this->identifiersExtractor = new IdentifiersExtractor($propertyNameCollectionFactory, $propertyMetadataFactory, $propertyAccessor ?? PropertyAccess::createPropertyAccessor()); } + $this->resourceMetadataFactory = $resourceMetadataFactory; } /** @@ -115,7 +116,7 @@ public function getItemFromIri(string $iri, array $context = []) /** * {@inheritdoc} */ - public function getIriFromItem($item, int $referenceType = UrlGeneratorInterface::ABS_PATH): string + public function getIriFromItem($item, int $referenceType = null): string { $resourceClass = $this->getResourceClass($item, true); @@ -125,16 +126,16 @@ public function getIriFromItem($item, int $referenceType = UrlGeneratorInterface throw new InvalidArgumentException(sprintf('Unable to generate an IRI for the item of type "%s"', $resourceClass), $e->getCode(), $e); } - return $this->getItemIriFromResourceClass($resourceClass, $identifiers, $referenceType); + return $this->getItemIriFromResourceClass($resourceClass, $identifiers, $this->getReferenceType($resourceClass, $referenceType)); } /** * {@inheritdoc} */ - public function getIriFromResourceClass(string $resourceClass, int $referenceType = UrlGeneratorInterface::ABS_PATH): string + public function getIriFromResourceClass(string $resourceClass, int $referenceType = null): string { try { - return $this->router->generate($this->routeNameResolver->getRouteName($resourceClass, OperationType::COLLECTION), [], $referenceType); + return $this->router->generate($this->routeNameResolver->getRouteName($resourceClass, OperationType::COLLECTION), [], $this->getReferenceType($resourceClass, $referenceType)); } catch (RoutingExceptionInterface $e) { throw new InvalidArgumentException(sprintf('Unable to generate an IRI for "%s".', $resourceClass), $e->getCode(), $e); } @@ -143,14 +144,14 @@ public function getIriFromResourceClass(string $resourceClass, int $referenceTyp /** * {@inheritdoc} */ - public function getItemIriFromResourceClass(string $resourceClass, array $identifiers, int $referenceType = UrlGeneratorInterface::ABS_PATH): string + public function getItemIriFromResourceClass(string $resourceClass, array $identifiers, int $referenceType = null): string { $routeName = $this->routeNameResolver->getRouteName($resourceClass, OperationType::ITEM); try { $identifiers = $this->generateIdentifiersUrl($identifiers, $resourceClass); - return $this->router->generate($routeName, ['id' => implode(';', $identifiers)], $referenceType); + return $this->router->generate($routeName, ['id' => implode(';', $identifiers)], $this->getReferenceType($resourceClass, $referenceType)); } catch (RoutingExceptionInterface $e) { throw new InvalidArgumentException(sprintf('Unable to generate an IRI for "%s".', $resourceClass), $e->getCode(), $e); } @@ -159,10 +160,10 @@ public function getItemIriFromResourceClass(string $resourceClass, array $identi /** * {@inheritdoc} */ - public function getSubresourceIriFromResourceClass(string $resourceClass, array $context, int $referenceType = UrlGeneratorInterface::ABS_PATH): string + public function getSubresourceIriFromResourceClass(string $resourceClass, array $context, int $referenceType = null): string { try { - return $this->router->generate($this->routeNameResolver->getRouteName($resourceClass, OperationType::SUBRESOURCE, $context), $context['subresource_identifiers'], $referenceType); + return $this->router->generate($this->routeNameResolver->getRouteName($resourceClass, OperationType::SUBRESOURCE, $context), $context['subresource_identifiers'], $this->getReferenceType($resourceClass, $referenceType)); } catch (RoutingExceptionInterface $e) { throw new InvalidArgumentException(sprintf('Unable to generate an IRI for "%s".', $resourceClass), $e->getCode(), $e); } @@ -191,4 +192,14 @@ private function generateIdentifiersUrl(array $identifiers, string $resourceClas return array_values($identifiers); } + + private function getReferenceType(string $resourceClass, ?int $referenceType): ?int + { + if (null === $referenceType && null !== $this->resourceMetadataFactory) { + $metadata = $this->resourceMetadataFactory->create($resourceClass); + $referenceType = $metadata->getAttribute('url_generation_strategy'); + } + + return $referenceType; + } } diff --git a/src/Bridge/Symfony/Routing/Router.php b/src/Bridge/Symfony/Routing/Router.php index 3959a6c39ed..52aecd59c96 100644 --- a/src/Bridge/Symfony/Routing/Router.php +++ b/src/Bridge/Symfony/Routing/Router.php @@ -35,10 +35,12 @@ final class Router implements RouterInterface, UrlGeneratorInterface ]; private $router; + private $urlGenerationStrategy; - public function __construct(RouterInterface $router) + public function __construct(RouterInterface $router, int $urlGenerationStrategy = self::ABS_PATH) { $this->router = $router; + $this->urlGenerationStrategy = $urlGenerationStrategy; } /** @@ -96,8 +98,8 @@ public function match($pathInfo) /** * {@inheritdoc} */ - public function generate($name, $parameters = [], $referenceType = self::ABS_PATH) + public function generate($name, $parameters = [], $referenceType = null) { - return $this->router->generate($name, $parameters, self::CONST_MAP[$referenceType]); + return $this->router->generate($name, $parameters, self::CONST_MAP[$referenceType ?? $this->urlGenerationStrategy]); } } diff --git a/src/Hal/Serializer/CollectionNormalizer.php b/src/Hal/Serializer/CollectionNormalizer.php index d8266e9f406..8f00554de2f 100644 --- a/src/Hal/Serializer/CollectionNormalizer.php +++ b/src/Hal/Serializer/CollectionNormalizer.php @@ -13,6 +13,8 @@ namespace ApiPlatform\Core\Hal\Serializer; +use ApiPlatform\Core\Api\ResourceClassResolverInterface; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use ApiPlatform\Core\Serializer\AbstractCollectionNormalizer; use ApiPlatform\Core\Util\IriHelper; use Symfony\Component\Serializer\Exception\UnexpectedValueException; @@ -27,32 +29,40 @@ final class CollectionNormalizer extends AbstractCollectionNormalizer { public const FORMAT = 'jsonhal'; + public function __construct(ResourceClassResolverInterface $resourceClassResolver, string $pageParameterName, ResourceMetadataFactoryInterface $resourceMetadataFactory) + { + parent::__construct($resourceClassResolver, $pageParameterName, $resourceMetadataFactory); + } + /** * {@inheritdoc} */ protected function getPaginationData($object, array $context = []): array { [$paginator, $paginated, $currentPage, $itemsPerPage, $lastPage, $pageTotalItems, $totalItems] = $this->getPaginationConfig($object, $context); - $parsed = IriHelper::parseIri($context['request_uri'] ?? '/', $this->pageParameterName); + $parsed = IriHelper::parseIri($context['uri'] ?? '/', $this->pageParameterName); + + $metadata = $this->resourceMetadataFactory->create($context['resource_class'] ?? ''); + $urlGenerationStrategy = $metadata->getAttribute('url_generation_strategy'); $data = [ '_links' => [ - 'self' => ['href' => IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $paginated ? $currentPage : null)], + 'self' => ['href' => IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $paginated ? $currentPage : null, $urlGenerationStrategy)], ], ]; if ($paginated) { if (null !== $lastPage) { - $data['_links']['first']['href'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, 1.); - $data['_links']['last']['href'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $lastPage); + $data['_links']['first']['href'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, 1., $urlGenerationStrategy); + $data['_links']['last']['href'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $lastPage, $urlGenerationStrategy); } if (1. !== $currentPage) { - $data['_links']['prev']['href'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $currentPage - 1.); + $data['_links']['prev']['href'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $currentPage - 1., $urlGenerationStrategy); } if ((null !== $lastPage && $currentPage !== $lastPage) || (null === $lastPage && $pageTotalItems >= $itemsPerPage)) { - $data['_links']['next']['href'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $currentPage + 1.); + $data['_links']['next']['href'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $currentPage + 1., $urlGenerationStrategy); } } diff --git a/src/JsonApi/Serializer/CollectionNormalizer.php b/src/JsonApi/Serializer/CollectionNormalizer.php index c92b2548f13..c0e920f9f29 100644 --- a/src/JsonApi/Serializer/CollectionNormalizer.php +++ b/src/JsonApi/Serializer/CollectionNormalizer.php @@ -13,6 +13,8 @@ namespace ApiPlatform\Core\JsonApi\Serializer; +use ApiPlatform\Core\Api\ResourceClassResolverInterface; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use ApiPlatform\Core\Serializer\AbstractCollectionNormalizer; use ApiPlatform\Core\Util\IriHelper; use Symfony\Component\Serializer\Exception\UnexpectedValueException; @@ -28,32 +30,40 @@ final class CollectionNormalizer extends AbstractCollectionNormalizer { public const FORMAT = 'jsonapi'; + public function __construct(ResourceClassResolverInterface $resourceClassResolver, string $pageParameterName, ResourceMetadataFactoryInterface $resourceMetadataFactory) + { + parent::__construct($resourceClassResolver, $pageParameterName, $resourceMetadataFactory); + } + /** * {@inheritdoc} */ protected function getPaginationData($object, array $context = []): array { [$paginator, $paginated, $currentPage, $itemsPerPage, $lastPage, $pageTotalItems, $totalItems] = $this->getPaginationConfig($object, $context); - $parsed = IriHelper::parseIri($context['request_uri'] ?? '/', $this->pageParameterName); + $parsed = IriHelper::parseIri($context['uri'] ?? '/', $this->pageParameterName); + + $metadata = $this->resourceMetadataFactory->create($context['resource_class'] ?? ''); + $urlGenerationStrategy = $metadata->getAttribute('url_generation_strategy'); $data = [ 'links' => [ - 'self' => IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $paginated ? $currentPage : null), + 'self' => IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $paginated ? $currentPage : null, $urlGenerationStrategy), ], ]; if ($paginated) { if (null !== $lastPage) { - $data['links']['first'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, 1.); - $data['links']['last'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $lastPage); + $data['links']['first'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, 1., $urlGenerationStrategy); + $data['links']['last'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $lastPage, $urlGenerationStrategy); } if (1. !== $currentPage) { - $data['links']['prev'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $currentPage - 1.); + $data['links']['prev'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $currentPage - 1., $urlGenerationStrategy); } if (null !== $lastPage && $currentPage !== $lastPage || null === $lastPage && $pageTotalItems >= $itemsPerPage) { - $data['links']['next'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $currentPage + 1.); + $data['links']['next'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $currentPage + 1., $urlGenerationStrategy); } } diff --git a/src/JsonLd/ContextBuilder.php b/src/JsonLd/ContextBuilder.php index 88fdf1c08e4..625e3e7287a 100644 --- a/src/JsonLd/ContextBuilder.php +++ b/src/JsonLd/ContextBuilder.php @@ -90,8 +90,8 @@ public function getEntrypointContext(int $referenceType = UrlGeneratorInterface: */ public function getResourceContext(string $resourceClass, int $referenceType = UrlGeneratorInterface::ABS_PATH): array { - $metadata = $this->resourceMetadataFactory->create($resourceClass); - if (null === $shortName = $metadata->getShortName()) { + $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass); + if (null === $shortName = $resourceMetadata->getShortName()) { return []; } @@ -101,9 +101,12 @@ public function getResourceContext(string $resourceClass, int $referenceType = U /** * {@inheritdoc} */ - public function getResourceContextUri(string $resourceClass, int $referenceType = UrlGeneratorInterface::ABS_PATH): string + public function getResourceContextUri(string $resourceClass, int $referenceType = null): string { $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass); + if (null === $referenceType) { + $referenceType = $resourceMetadata->getAttribute('url_generation_strategy'); + } return $this->urlGenerator->generate('api_jsonld_context', ['shortName' => $resourceMetadata->getShortName()], $referenceType); } diff --git a/src/Serializer/AbstractCollectionNormalizer.php b/src/Serializer/AbstractCollectionNormalizer.php index 59268475747..4af7d4c456e 100644 --- a/src/Serializer/AbstractCollectionNormalizer.php +++ b/src/Serializer/AbstractCollectionNormalizer.php @@ -16,6 +16,7 @@ use ApiPlatform\Core\Api\ResourceClassResolverInterface; use ApiPlatform\Core\DataProvider\PaginatorInterface; use ApiPlatform\Core\DataProvider\PartialPaginatorInterface; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use Symfony\Component\Serializer\Normalizer\CacheableSupportsMethodInterface; use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface; use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait; @@ -40,11 +41,13 @@ abstract class AbstractCollectionNormalizer implements NormalizerInterface, Norm protected $resourceClassResolver; protected $pageParameterName; + protected $resourceMetadataFactory; - public function __construct(ResourceClassResolverInterface $resourceClassResolver, string $pageParameterName) + public function __construct(ResourceClassResolverInterface $resourceClassResolver, string $pageParameterName, ResourceMetadataFactoryInterface $resourceMetadataFactory = null) { $this->resourceClassResolver = $resourceClassResolver; $this->pageParameterName = $pageParameterName; + $this->resourceMetadataFactory = $resourceMetadataFactory; } /** diff --git a/src/Util/IriHelper.php b/src/Util/IriHelper.php index d88a61f0592..4762a1e20d1 100644 --- a/src/Util/IriHelper.php +++ b/src/Util/IriHelper.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Core\Util; +use ApiPlatform\Core\Api\UrlGeneratorInterface; use ApiPlatform\Core\Exception\InvalidArgumentException; /** @@ -56,27 +57,30 @@ public static function parseIri(string $iri, string $pageParameterName): array * * @param float $page */ - public static function createIri(array $parts, array $parameters, string $pageParameterName = null, float $page = null, bool $absoluteUrl = false): string + public static function createIri(array $parts, array $parameters, string $pageParameterName = null, float $page = null, $urlGenerationStrategy = UrlGeneratorInterface::ABS_PATH): string { if (null !== $page && null !== $pageParameterName) { $parameters[$pageParameterName] = $page; } + if (\is_bool($urlGenerationStrategy)) { + @trigger_error(sprintf('Passing a bool as 5th parameter to "%s::createIri()" is deprecated since API Platform 2.6. Pass an "%s" constant (int) instead.', __CLASS__, UrlGeneratorInterface::class), E_USER_DEPRECATED); + $urlGenerationStrategy = $urlGenerationStrategy ? UrlGeneratorInterface::ABS_URL : UrlGeneratorInterface::ABS_PATH; + } + $query = http_build_query($parameters, '', '&', PHP_QUERY_RFC3986); $parts['query'] = preg_replace('/%5B\d+%5D/', '%5B%5D', $query); $url = ''; - - if ($absoluteUrl && isset($parts['host'])) { + if ((UrlGeneratorInterface::ABS_URL === $urlGenerationStrategy || UrlGeneratorInterface::NET_PATH === $urlGenerationStrategy) && isset($parts['host'])) { if (isset($parts['scheme'])) { - $url .= $parts['scheme']; + $scheme = $parts['scheme']; } elseif (isset($parts['port']) && 443 === $parts['port']) { - $url .= 'https'; + $scheme = 'https'; } else { - $url .= 'http'; + $scheme = 'http'; } - - $url .= '://'; + $url .= UrlGeneratorInterface::NET_PATH === $urlGenerationStrategy ? '//' : "$scheme://"; if (isset($parts['user'])) { $url .= $parts['user']; diff --git a/tests/Annotation/ApiResourceTest.php b/tests/Annotation/ApiResourceTest.php index 5f24c6aacca..48e040c0ac9 100644 --- a/tests/Annotation/ApiResourceTest.php +++ b/tests/Annotation/ApiResourceTest.php @@ -14,6 +14,7 @@ namespace ApiPlatform\Core\Tests\Annotation; use ApiPlatform\Core\Annotation\ApiResource; +use ApiPlatform\Core\Api\UrlGeneratorInterface; use ApiPlatform\Core\Exception\InvalidArgumentException; use ApiPlatform\Core\Tests\Fixtures\AnnotatedClass; use Doctrine\Common\Annotations\AnnotationReader; @@ -64,6 +65,7 @@ public function testConstruct() 'swaggerContext' => ['description' => 'bar'], 'validationGroups' => ['foo', 'bar'], 'sunset' => 'Thu, 11 Oct 2018 00:00:00 +0200', + 'urlGenerationStrategy' => UrlGeneratorInterface::ABS_PATH, ]); $this->assertSame('shortName', $resource->shortName); @@ -105,6 +107,7 @@ public function testConstruct() 'validation_groups' => ['baz', 'qux'], 'cache_headers' => ['max_age' => 0, 'shared_max_age' => 0, 'vary' => ['Custom-Vary-1', 'Custom-Vary-2']], 'sunset' => 'Thu, 11 Oct 2018 00:00:00 +0200', + 'url_generation_strategy' => 1, ], $resource->attributes); } diff --git a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php index dec70e86d7b..9ada175bd00 100644 --- a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php +++ b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php @@ -862,6 +862,7 @@ private function getPartialContainerBuilderProphecy($configuration = null) 'api_platform.defaults' => ['attributes' => []], 'api_platform.enable_entrypoint' => true, 'api_platform.enable_docs' => true, + 'api_platform.url_generation_strategy' => 1, ]; $pagination = [ diff --git a/tests/Bridge/Symfony/Routing/IriConverterTest.php b/tests/Bridge/Symfony/Routing/IriConverterTest.php index 81f5fcc911b..365c6b1cd71 100644 --- a/tests/Bridge/Symfony/Routing/IriConverterTest.php +++ b/tests/Bridge/Symfony/Routing/IriConverterTest.php @@ -27,6 +27,8 @@ use ApiPlatform\Core\Identifier\IdentifierConverterInterface; use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\RelatedDummy; use PHPUnit\Framework\TestCase; @@ -139,12 +141,27 @@ public function testGetIriFromResourceClass() $routeNameResolverProphecy->getRouteName(Dummy::class, OperationType::COLLECTION)->willReturn('dummies'); $routerProphecy = $this->prophesize(RouterInterface::class); - $routerProphecy->generate('dummies', [], UrlGeneratorInterface::ABS_PATH)->willReturn('/dummies'); + $routerProphecy->generate('dummies', [], null)->willReturn('/dummies'); $converter = $this->getIriConverter($routerProphecy, $routeNameResolverProphecy); $this->assertEquals($converter->getIriFromResourceClass(Dummy::class), '/dummies'); } + public function testGetIriFromResourceClassAbsoluteUrl() + { + $routeNameResolverProphecy = $this->prophesize(RouteNameResolverInterface::class); + $routeNameResolverProphecy->getRouteName(Dummy::class, OperationType::COLLECTION)->willReturn('dummies'); + + $routerProphecy = $this->prophesize(RouterInterface::class); + $routerProphecy->generate('dummies', [], UrlGeneratorInterface::ABS_URL)->willReturn('http://example.com/dummies'); + + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataFactoryProphecy->create(Dummy::class)->willReturn(new ResourceMetadata('', '', '', [], [], ['url_generation_strategy' => UrlGeneratorInterface::ABS_URL])); + + $converter = $this->getIriConverter($routerProphecy, $routeNameResolverProphecy, null, null, null, $resourceMetadataFactoryProphecy->reveal()); + $this->assertEquals($converter->getIriFromResourceClass(Dummy::class), 'http://example.com/dummies'); + } + public function testNotAbleToGenerateGetIriFromResourceClass() { $this->expectException(InvalidArgumentException::class); @@ -154,7 +171,7 @@ public function testNotAbleToGenerateGetIriFromResourceClass() $routeNameResolverProphecy->getRouteName(Dummy::class, OperationType::COLLECTION)->willReturn('dummies'); $routerProphecy = $this->prophesize(RouterInterface::class); - $routerProphecy->generate('dummies', [], UrlGeneratorInterface::ABS_PATH)->willThrow(new RouteNotFoundException()); + $routerProphecy->generate('dummies', [], null)->willThrow(new RouteNotFoundException()); $converter = $this->getIriConverter($routerProphecy, $routeNameResolverProphecy); $converter->getIriFromResourceClass(Dummy::class); @@ -166,7 +183,7 @@ public function testGetSubresourceIriFromResourceClass() $routeNameResolverProphecy->getRouteName(Dummy::class, OperationType::SUBRESOURCE, Argument::type('array'))->willReturn('api_dummies_related_dummies_get_subresource'); $routerProphecy = $this->prophesize(RouterInterface::class); - $routerProphecy->generate('api_dummies_related_dummies_get_subresource', ['id' => 1], UrlGeneratorInterface::ABS_PATH)->willReturn('/dummies/1/related_dummies'); + $routerProphecy->generate('api_dummies_related_dummies_get_subresource', ['id' => 1], null)->willReturn('/dummies/1/related_dummies'); $converter = $this->getIriConverter($routerProphecy, $routeNameResolverProphecy); $this->assertEquals($converter->getSubresourceIriFromResourceClass(Dummy::class, ['subresource_identifiers' => ['id' => 1], 'subresource_resources' => [RelatedDummy::class => 1]]), '/dummies/1/related_dummies'); @@ -181,7 +198,7 @@ public function testNotAbleToGenerateGetSubresourceIriFromResourceClass() $routeNameResolverProphecy->getRouteName(Dummy::class, OperationType::SUBRESOURCE, Argument::type('array'))->willReturn('dummies'); $routerProphecy = $this->prophesize(RouterInterface::class); - $routerProphecy->generate('dummies', ['id' => 1], UrlGeneratorInterface::ABS_PATH)->willThrow(new RouteNotFoundException()); + $routerProphecy->generate('dummies', ['id' => 1], null)->willThrow(new RouteNotFoundException()); $converter = $this->getIriConverter($routerProphecy, $routeNameResolverProphecy); $converter->getSubresourceIriFromResourceClass(Dummy::class, ['subresource_identifiers' => ['id' => 1], 'subresource_resources' => [RelatedDummy::class => 1]]); @@ -193,12 +210,27 @@ public function testGetItemIriFromResourceClass() $routeNameResolverProphecy->getRouteName(Dummy::class, OperationType::ITEM)->willReturn('api_dummies_get_item'); $routerProphecy = $this->prophesize(RouterInterface::class); - $routerProphecy->generate('api_dummies_get_item', ['id' => 1], UrlGeneratorInterface::ABS_PATH)->willReturn('/dummies/1'); + $routerProphecy->generate('api_dummies_get_item', ['id' => 1], null)->willReturn('/dummies/1'); $converter = $this->getIriConverter($routerProphecy, $routeNameResolverProphecy); $this->assertEquals($converter->getItemIriFromResourceClass(Dummy::class, ['id' => 1]), '/dummies/1'); } + public function testGetItemIriFromResourceClassAbsoluteUrl() + { + $routeNameResolverProphecy = $this->prophesize(RouteNameResolverInterface::class); + $routeNameResolverProphecy->getRouteName(Dummy::class, OperationType::ITEM)->willReturn('api_dummies_get_item'); + + $routerProphecy = $this->prophesize(RouterInterface::class); + $routerProphecy->generate('api_dummies_get_item', ['id' => 1], UrlGeneratorInterface::ABS_URL)->willReturn('http://example.com/dummies/1'); + + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataFactoryProphecy->create(Dummy::class)->willReturn(new ResourceMetadata('', '', '', [], [], ['url_generation_strategy' => UrlGeneratorInterface::ABS_URL])); + + $converter = $this->getIriConverter($routerProphecy, $routeNameResolverProphecy, null, null, null, $resourceMetadataFactoryProphecy->reveal()); + $this->assertEquals($converter->getItemIriFromResourceClass(Dummy::class, ['id' => 1]), 'http://example.com/dummies/1'); + } + public function testNotAbleToGenerateGetItemIriFromResourceClass() { $this->expectException(InvalidArgumentException::class); @@ -208,7 +240,7 @@ public function testNotAbleToGenerateGetItemIriFromResourceClass() $routeNameResolverProphecy->getRouteName(Dummy::class, OperationType::ITEM)->willReturn('dummies'); $routerProphecy = $this->prophesize(RouterInterface::class); - $routerProphecy->generate('dummies', ['id' => 1], UrlGeneratorInterface::ABS_PATH)->willThrow(new RouteNotFoundException()); + $routerProphecy->generate('dummies', ['id' => 1], null)->willThrow(new RouteNotFoundException()); $converter = $this->getIriConverter($routerProphecy, $routeNameResolverProphecy); $converter->getItemIriFromResourceClass(Dummy::class, ['id' => 1]); @@ -339,7 +371,7 @@ private function getResourceClassResolver() return $resourceClassResolver->reveal(); } - private function getIriConverter($routerProphecy = null, $routeNameResolverProphecy = null, $itemDataProviderProphecy = null, $subresourceDataProviderProphecy = null, $identifierConverterProphecy = null) + private function getIriConverter($routerProphecy = null, $routeNameResolverProphecy = null, $itemDataProviderProphecy = null, $subresourceDataProviderProphecy = null, $identifierConverterProphecy = null, ResourceMetadataFactoryInterface $resourceMetadataFactory = null) { $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); @@ -366,7 +398,9 @@ private function getIriConverter($routerProphecy = null, $routeNameResolverProph null, new IdentifiersExtractor($propertyNameCollectionFactory, $propertyMetadataFactory, null, $this->getResourceClassResolver()), $subresourceDataProviderProphecy ? $subresourceDataProviderProphecy->reveal() : null, - $identifierConverterProphecy ? $identifierConverterProphecy->reveal() : null + $identifierConverterProphecy ? $identifierConverterProphecy->reveal() : null, + null, + $resourceMetadataFactory ); } } diff --git a/tests/Bridge/Symfony/Routing/RouterTest.php b/tests/Bridge/Symfony/Routing/RouterTest.php index 9f498eab771..3c6721c9e3f 100644 --- a/tests/Bridge/Symfony/Routing/RouterTest.php +++ b/tests/Bridge/Symfony/Routing/RouterTest.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Core\Tests\Bridge\Symfony\Routing; +use ApiPlatform\Core\Api\UrlGeneratorInterface; use ApiPlatform\Core\Bridge\Symfony\Routing\Router; use PHPUnit\Framework\TestCase; use Prophecy\Argument; @@ -59,6 +60,24 @@ public function testGenerate() $this->assertSame('/bar', $router->generate('foo')); } + public function testGenerateWithDefaultStrategy() + { + $mockedRouter = $this->prophesize(RouterInterface::class); + $mockedRouter->generate('foo', [], UrlGeneratorInterface::ABS_URL)->willReturn('/bar')->shouldBeCalled(); + + $router = new Router($mockedRouter->reveal(), UrlGeneratorInterface::ABS_URL); + $this->assertSame('/bar', $router->generate('foo')); + } + + public function testGenerateWithStrategy() + { + $mockedRouter = $this->prophesize(RouterInterface::class); + $mockedRouter->generate('foo', [], UrlGeneratorInterface::ABS_URL)->willReturn('/bar')->shouldBeCalled(); + + $router = new Router($mockedRouter->reveal()); + $this->assertSame('/bar', $router->generate('foo', [], UrlGeneratorInterface::ABS_URL)); + } + public function testMatch() { $context = new RequestContext('/app_dev.php', 'GET', 'localhost', 'https'); diff --git a/tests/Fixtures/TestBundle/Document/AbsoluteUrlDummy.php b/tests/Fixtures/TestBundle/Document/AbsoluteUrlDummy.php new file mode 100644 index 00000000000..6997f3ab80a --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/AbsoluteUrlDummy.php @@ -0,0 +1,40 @@ + + * + * 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\Tests\Fixtures\TestBundle\Document; + +use ApiPlatform\Core\Annotation\ApiResource; +use ApiPlatform\Core\Api\UrlGeneratorInterface; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +/** + * @ApiResource(urlGenerationStrategy=UrlGeneratorInterface::ABS_URL) + * @ODM\Document + */ +class AbsoluteUrlDummy +{ + /** + * @ODM\Id(strategy="INCREMENT", type="integer") + */ + private $id; + + /** + * @ODM\ReferenceOne(targetDocument=AbsoluteUrlRelationDummy::class, inversedBy="absoluteUrlDummies", storeAs="id") + */ + public $absoluteUrlRelationDummy; + + public function getId() + { + return $this->id; + } +} diff --git a/tests/Fixtures/TestBundle/Document/AbsoluteUrlRelationDummy.php b/tests/Fixtures/TestBundle/Document/AbsoluteUrlRelationDummy.php new file mode 100644 index 00000000000..968c628c227 --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/AbsoluteUrlRelationDummy.php @@ -0,0 +1,48 @@ + + * + * 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\Tests\Fixtures\TestBundle\Document; + +use ApiPlatform\Core\Annotation\ApiResource; +use ApiPlatform\Core\Annotation\ApiSubresource; +use ApiPlatform\Core\Api\UrlGeneratorInterface; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +/** + * @ApiResource(urlGenerationStrategy=UrlGeneratorInterface::ABS_URL) + * @ODM\Document + */ +class AbsoluteUrlRelationDummy +{ + /** + * @ODM\Id(strategy="INCREMENT", type="integer") + */ + private $id; + + /** + * @ODM\ReferenceMany(targetDocument=AbsoluteUrlDummy::class, mappedBy="absoluteUrlRelationDummy") + * @ApiSubresource + */ + public $absoluteUrlDummies; + + public function __construct() + { + $this->absoluteUrlDummies = new ArrayCollection(); + } + + public function getId() + { + return $this->id; + } +} diff --git a/tests/Fixtures/TestBundle/Document/NetworkPathDummy.php b/tests/Fixtures/TestBundle/Document/NetworkPathDummy.php new file mode 100644 index 00000000000..1026bd41402 --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/NetworkPathDummy.php @@ -0,0 +1,40 @@ + + * + * 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\Tests\Fixtures\TestBundle\Document; + +use ApiPlatform\Core\Annotation\ApiResource; +use ApiPlatform\Core\Api\UrlGeneratorInterface; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +/** + * @ApiResource(urlGenerationStrategy=UrlGeneratorInterface::NET_PATH) + * @ODM\Document + */ +class NetworkPathDummy +{ + /** + * @ODM\Id(strategy="INCREMENT", type="integer") + */ + private $id; + + /** + * @ODM\ReferenceOne(targetDocument=NetworkPathRelationDummy::class, inversedBy="networkPathDummies", storeAs="id") + */ + public $networkPathRelationDummy; + + public function getId() + { + return $this->id; + } +} diff --git a/tests/Fixtures/TestBundle/Document/NetworkPathRelationDummy.php b/tests/Fixtures/TestBundle/Document/NetworkPathRelationDummy.php new file mode 100644 index 00000000000..05331574484 --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/NetworkPathRelationDummy.php @@ -0,0 +1,48 @@ + + * + * 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\Tests\Fixtures\TestBundle\Document; + +use ApiPlatform\Core\Annotation\ApiResource; +use ApiPlatform\Core\Annotation\ApiSubresource; +use ApiPlatform\Core\Api\UrlGeneratorInterface; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +/** + * @ApiResource(urlGenerationStrategy=UrlGeneratorInterface::NET_PATH) + * @ODM\Document + */ +class NetworkPathRelationDummy +{ + /** + * @ODM\Id(strategy="INCREMENT", type="integer") + */ + private $id; + + /** + * @ODM\ReferenceMany(targetDocument=NetworkPathDummy::class, mappedBy="networkPathRelationDummy") + * @ApiSubresource + */ + public $networkPathDummies; + + public function __construct() + { + $this->networkPathDummies = new ArrayCollection(); + } + + public function getId() + { + return $this->id; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/AbsoluteUrlDummy.php b/tests/Fixtures/TestBundle/Entity/AbsoluteUrlDummy.php new file mode 100644 index 00000000000..cff27c3ac83 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/AbsoluteUrlDummy.php @@ -0,0 +1,42 @@ + + * + * 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\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Core\Annotation\ApiResource; +use ApiPlatform\Core\Api\UrlGeneratorInterface; +use Doctrine\ORM\Mapping as ORM; + +/** + * @ApiResource(urlGenerationStrategy=UrlGeneratorInterface::ABS_URL) + * @ORM\Entity + */ +class AbsoluteUrlDummy +{ + /** + * @ORM\Id + * @ORM\GeneratedValue + * @ORM\Column(type="integer") + */ + private $id; + + /** + * @ORM\ManyToOne(targetEntity="AbsoluteUrlRelationDummy", inversedBy="absoluteUrlDummies") + */ + public $absoluteUrlRelationDummy; + + public function getId() + { + return $this->id; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/AbsoluteUrlRelationDummy.php b/tests/Fixtures/TestBundle/Entity/AbsoluteUrlRelationDummy.php new file mode 100644 index 00000000000..34090ee2bc9 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/AbsoluteUrlRelationDummy.php @@ -0,0 +1,50 @@ + + * + * 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\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Core\Annotation\ApiResource; +use ApiPlatform\Core\Annotation\ApiSubresource; +use ApiPlatform\Core\Api\UrlGeneratorInterface; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\ORM\Mapping as ORM; + +/** + * @ApiResource(urlGenerationStrategy=UrlGeneratorInterface::ABS_URL) + * @ORM\Entity + */ +class AbsoluteUrlRelationDummy +{ + /** + * @ORM\Id + * @ORM\GeneratedValue + * @ORM\Column(type="integer") + */ + private $id; + + /** + * @ORM\OneToMany(targetEntity="AbsoluteUrlDummy", mappedBy="absoluteUrlRelationDummy") + * @ApiSubresource + */ + public $absoluteUrlDummies; + + public function __construct() + { + $this->absoluteUrlDummies = new ArrayCollection(); + } + + public function getId() + { + return $this->id; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/NetworkPathDummy.php b/tests/Fixtures/TestBundle/Entity/NetworkPathDummy.php new file mode 100644 index 00000000000..29f7e8ea043 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/NetworkPathDummy.php @@ -0,0 +1,42 @@ + + * + * 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\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Core\Annotation\ApiResource; +use ApiPlatform\Core\Api\UrlGeneratorInterface; +use Doctrine\ORM\Mapping as ORM; + +/** + * @ApiResource(urlGenerationStrategy=UrlGeneratorInterface::NET_PATH) + * @ORM\Entity + */ +class NetworkPathDummy +{ + /** + * @ORM\Id + * @ORM\GeneratedValue + * @ORM\Column(type="integer") + */ + private $id; + + /** + * @ORM\ManyToOne(targetEntity="NetworkPathRelationDummy", inversedBy="networkPathDummies") + */ + public $networkPathRelationDummy; + + public function getId() + { + return $this->id; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/NetworkPathRelationDummy.php b/tests/Fixtures/TestBundle/Entity/NetworkPathRelationDummy.php new file mode 100644 index 00000000000..cfd35391c6d --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/NetworkPathRelationDummy.php @@ -0,0 +1,50 @@ + + * + * 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\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Core\Annotation\ApiResource; +use ApiPlatform\Core\Annotation\ApiSubresource; +use ApiPlatform\Core\Api\UrlGeneratorInterface; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\ORM\Mapping as ORM; + +/** + * @ApiResource(urlGenerationStrategy=UrlGeneratorInterface::NET_PATH) + * @ORM\Entity + */ +class NetworkPathRelationDummy +{ + /** + * @ORM\Id + * @ORM\GeneratedValue + * @ORM\Column(type="integer") + */ + private $id; + + /** + * @ORM\OneToMany(targetEntity="NetworkPathDummy", mappedBy="networkPathRelationDummy") + * @ApiSubresource + */ + public $networkPathDummies; + + public function __construct() + { + $this->networkPathDummies = new ArrayCollection(); + } + + public function getId() + { + return $this->id; + } +} diff --git a/tests/Hal/Serializer/CollectionNormalizerTest.php b/tests/Hal/Serializer/CollectionNormalizerTest.php index 9c63cd8f7f1..c00ede98312 100644 --- a/tests/Hal/Serializer/CollectionNormalizerTest.php +++ b/tests/Hal/Serializer/CollectionNormalizerTest.php @@ -17,6 +17,8 @@ use ApiPlatform\Core\DataProvider\PaginatorInterface; use ApiPlatform\Core\DataProvider\PartialPaginatorInterface; use ApiPlatform\Core\Hal\Serializer\CollectionNormalizer; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; use PHPUnit\Framework\TestCase; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; @@ -28,7 +30,8 @@ class CollectionNormalizerTest extends TestCase public function testSupportsNormalize() { $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); - $normalizer = new CollectionNormalizer($resourceClassResolverProphecy->reveal(), 'page'); + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $normalizer = new CollectionNormalizer($resourceClassResolverProphecy->reveal(), 'page', $resourceMetadataFactoryProphecy->reveal()); $this->assertTrue($normalizer->supportsNormalization([], CollectionNormalizer::FORMAT)); $this->assertTrue($normalizer->supportsNormalization(new \ArrayObject(), CollectionNormalizer::FORMAT)); @@ -41,11 +44,12 @@ public function testNormalizeApiSubLevel() { $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); $resourceClassResolverProphecy->getResourceClass()->shouldNotBeCalled(); + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); $itemNormalizer = $this->prophesize(NormalizerInterface::class); $itemNormalizer->normalize('bar', null, ['api_sub_level' => true])->willReturn(22); - $normalizer = new CollectionNormalizer($resourceClassResolverProphecy->reveal(), 'page'); + $normalizer = new CollectionNormalizer($resourceClassResolverProphecy->reveal(), 'page', $resourceMetadataFactoryProphecy->reveal()); $normalizer->setNormalizer($itemNormalizer->reveal()); $this->assertEquals(['foo' => 22], $normalizer->normalize(['foo' => 'bar'], null, ['api_sub_level' => true])); @@ -134,13 +138,16 @@ private function normalizePaginator($partial = false) $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); $resourceClassResolverProphecy->getResourceClass($paginatorProphecy, 'Foo')->willReturn('Foo'); + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataFactoryProphecy->create('Foo')->willReturn(new ResourceMetadata()); + $itemNormalizer = $this->prophesize(NormalizerInterface::class); $itemNormalizer->normalize('foo', CollectionNormalizer::FORMAT, [ 'api_sub_level' => true, 'resource_class' => 'Foo', ])->willReturn(['_links' => ['self' => '/me'], 'name' => 'Kévin']); - $normalizer = new CollectionNormalizer($resourceClassResolverProphecy->reveal(), 'page'); + $normalizer = new CollectionNormalizer($resourceClassResolverProphecy->reveal(), 'page', $resourceMetadataFactoryProphecy->reveal()); $normalizer->setNormalizer($itemNormalizer->reveal()); return $normalizer->normalize($paginatorProphecy->reveal(), CollectionNormalizer::FORMAT, [ diff --git a/tests/JsonApi/Serializer/CollectionNormalizerTest.php b/tests/JsonApi/Serializer/CollectionNormalizerTest.php index 7096379e25d..da7248ec468 100644 --- a/tests/JsonApi/Serializer/CollectionNormalizerTest.php +++ b/tests/JsonApi/Serializer/CollectionNormalizerTest.php @@ -17,6 +17,8 @@ use ApiPlatform\Core\DataProvider\PaginatorInterface; use ApiPlatform\Core\DataProvider\PartialPaginatorInterface; use ApiPlatform\Core\JsonApi\Serializer\CollectionNormalizer; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; use PHPUnit\Framework\TestCase; use Symfony\Component\Serializer\Exception\UnexpectedValueException; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; @@ -29,8 +31,9 @@ class CollectionNormalizerTest extends TestCase public function testSupportsNormalize() { $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); - $normalizer = new CollectionNormalizer($resourceClassResolverProphecy->reveal(), 'page'); + $normalizer = new CollectionNormalizer($resourceClassResolverProphecy->reveal(), 'page', $resourceMetadataFactoryProphecy->reveal()); $this->assertTrue($normalizer->supportsNormalization([], CollectionNormalizer::FORMAT)); $this->assertTrue($normalizer->supportsNormalization(new \ArrayObject(), CollectionNormalizer::FORMAT)); @@ -56,9 +59,13 @@ public function testNormalizePaginator() $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); $resourceClassResolverProphecy->getResourceClass($paginator, 'Foo')->willReturn('Foo'); + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataFactoryProphecy->create('Foo')->willReturn(new ResourceMetadata()); + $itemNormalizer = $this->prophesize(NormalizerInterface::class); $itemNormalizer->normalize('foo', CollectionNormalizer::FORMAT, [ 'request_uri' => '/foos?page=3', + 'uri' => 'http://example.com/foos?page=3', 'api_sub_level' => true, 'resource_class' => 'Foo', ])->willReturn([ @@ -72,7 +79,7 @@ public function testNormalizePaginator() ], ]); - $normalizer = new CollectionNormalizer($resourceClassResolverProphecy->reveal(), 'page'); + $normalizer = new CollectionNormalizer($resourceClassResolverProphecy->reveal(), 'page', $resourceMetadataFactoryProphecy->reveal()); $normalizer->setNormalizer($itemNormalizer->reveal()); $expected = [ @@ -102,6 +109,7 @@ public function testNormalizePaginator() $this->assertEquals($expected, $normalizer->normalize($paginator, CollectionNormalizer::FORMAT, [ 'request_uri' => '/foos?page=3', + 'uri' => 'http://example.com/foos?page=3', 'resource_class' => 'Foo', ])); } @@ -122,9 +130,13 @@ public function testNormalizePartialPaginator() $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); $resourceClassResolverProphecy->getResourceClass($paginator, 'Foo')->willReturn('Foo'); + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataFactoryProphecy->create('Foo')->willReturn(new ResourceMetadata()); + $itemNormalizer = $this->prophesize(NormalizerInterface::class); $itemNormalizer->normalize('foo', CollectionNormalizer::FORMAT, [ 'request_uri' => '/foos?page=3', + 'uri' => 'http://example.com/foos?page=3', 'api_sub_level' => true, 'resource_class' => 'Foo', ])->willReturn([ @@ -138,7 +150,7 @@ public function testNormalizePartialPaginator() ], ]); - $normalizer = new CollectionNormalizer($resourceClassResolverProphecy->reveal(), 'page'); + $normalizer = new CollectionNormalizer($resourceClassResolverProphecy->reveal(), 'page', $resourceMetadataFactoryProphecy->reveal()); $normalizer->setNormalizer($itemNormalizer->reveal()); $expected = [ @@ -165,6 +177,7 @@ public function testNormalizePartialPaginator() $this->assertEquals($expected, $normalizer->normalize($paginator, CollectionNormalizer::FORMAT, [ 'request_uri' => '/foos?page=3', + 'uri' => 'http://example.com/foos?page=3', 'resource_class' => 'Foo', ])); } @@ -175,10 +188,12 @@ public function testNormalizeArray() $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); $resourceClassResolverProphecy->getResourceClass($data, 'Foo')->willReturn('Foo'); - + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataFactoryProphecy->create('Foo')->willReturn(new ResourceMetadata()); $itemNormalizer = $this->prophesize(NormalizerInterface::class); $itemNormalizer->normalize('foo', CollectionNormalizer::FORMAT, [ 'request_uri' => '/foos', + 'uri' => 'http://example.com/foos', 'api_sub_level' => true, 'resource_class' => 'Foo', ])->willReturn([ @@ -192,7 +207,7 @@ public function testNormalizeArray() ], ]); - $normalizer = new CollectionNormalizer($resourceClassResolverProphecy->reveal(), 'page'); + $normalizer = new CollectionNormalizer($resourceClassResolverProphecy->reveal(), 'page', $resourceMetadataFactoryProphecy->reveal()); $normalizer->setNormalizer($itemNormalizer->reveal()); $expected = [ @@ -212,6 +227,7 @@ public function testNormalizeArray() $this->assertEquals($expected, $normalizer->normalize($data, CollectionNormalizer::FORMAT, [ 'request_uri' => '/foos', + 'uri' => 'http://example.com/foos', 'resource_class' => 'Foo', ])); } @@ -223,9 +239,13 @@ public function testNormalizeIncludedData() $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); $resourceClassResolverProphecy->getResourceClass($data, 'Foo')->willReturn('Foo'); + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataFactoryProphecy->create('Foo')->willReturn(new ResourceMetadata()); + $itemNormalizer = $this->prophesize(NormalizerInterface::class); $itemNormalizer->normalize('foo', CollectionNormalizer::FORMAT, [ 'request_uri' => '/foos', + 'uri' => 'http://example.com/foos', 'api_sub_level' => true, 'resource_class' => 'Foo', ])->willReturn([ @@ -249,7 +269,7 @@ public function testNormalizeIncludedData() ], ]); - $normalizer = new CollectionNormalizer($resourceClassResolverProphecy->reveal(), 'page'); + $normalizer = new CollectionNormalizer($resourceClassResolverProphecy->reveal(), 'page', $resourceMetadataFactoryProphecy->reveal()); $normalizer->setNormalizer($itemNormalizer->reveal()); $expected = [ @@ -279,6 +299,7 @@ public function testNormalizeIncludedData() $this->assertEquals($expected, $normalizer->normalize($data, CollectionNormalizer::FORMAT, [ 'request_uri' => '/foos', + 'uri' => 'http://example.com/foos', 'resource_class' => 'Foo', ])); } @@ -293,18 +314,23 @@ public function testNormalizeWithoutDataKey() $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); $resourceClassResolverProphecy->getResourceClass($data, 'Foo')->willReturn('Foo'); + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataFactoryProphecy->create('Foo')->willReturn(new ResourceMetadata()); + $itemNormalizer = $this->prophesize(NormalizerInterface::class); $itemNormalizer->normalize('foo', CollectionNormalizer::FORMAT, [ 'request_uri' => '/foos', + 'uri' => 'http://example.com/foos', 'api_sub_level' => true, 'resource_class' => 'Foo', ])->willReturn([]); - $normalizer = new CollectionNormalizer($resourceClassResolverProphecy->reveal(), 'page'); + $normalizer = new CollectionNormalizer($resourceClassResolverProphecy->reveal(), 'page', $resourceMetadataFactoryProphecy->reveal()); $normalizer->setNormalizer($itemNormalizer->reveal()); $normalizer->normalize($data, CollectionNormalizer::FORMAT, [ 'request_uri' => '/foos', + 'uri' => 'http://example.com/foos', 'resource_class' => 'Foo', ]); } diff --git a/tests/Util/IriHelperTest.php b/tests/Util/IriHelperTest.php index 8e6814f2404..83e61df47c1 100644 --- a/tests/Util/IriHelperTest.php +++ b/tests/Util/IriHelperTest.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Core\Tests\Util; +use ApiPlatform\Core\Api\UrlGeneratorInterface; use ApiPlatform\Core\Exception\InvalidArgumentException; use ApiPlatform\Core\Util\IriHelper; use PHPUnit\Framework\TestCase; @@ -39,7 +40,32 @@ public function testHelpers() $this->assertEquals('/hello.json?foo=bar&bar=3&page=2', IriHelper::createIri($parsed['parts'], $parsed['parameters'], 'page', 2.)); } - public function testHelpersWithAbsoluteUrl() + /** + * @group legacy + * @expectedDeprecation Passing a bool as 5th parameter to "ApiPlatform\Core\Util\IriHelper::createIri()" is deprecated since API Platform 2.6. Pass an "ApiPlatform\Core\Api\UrlGeneratorInterface" constant (int) instead. + */ + public function testLegacyHelpers() + { + $parsed = [ + 'parts' => [ + 'path' => '/hello.json', + 'query' => 'foo=bar&page=2&bar=3', + ], + 'parameters' => [ + 'foo' => 'bar', + 'bar' => '3', + ], + ]; + + $this->assertEquals($parsed, IriHelper::parseIri('/hello.json?foo=bar&page=2&bar=3', 'page')); + $this->assertEquals('/hello.json?foo=bar&bar=3&page=2', IriHelper::createIri($parsed['parts'], $parsed['parameters'], 'page', 2., false)); + } + + /** + * @group legacy + * @expectedDeprecation Passing a bool as 5th parameter to "ApiPlatform\Core\Util\IriHelper::createIri()" is deprecated since API Platform 2.6. Pass an "ApiPlatform\Core\Api\UrlGeneratorInterface" constant (int) instead. + */ + public function testLegacyHelpersWithAbsoluteUrl() { $parsed = [ 'parts' => [ @@ -70,6 +96,36 @@ public function testHelpersWithAbsoluteUrl() $this->assertEquals('https://foo:bar@localhost:443/hello.json?foo=bar&bar=3&page=2#foo', IriHelper::createIri($parsed['parts'], $parsed['parameters'], 'page', 2., true)); } + public function testHelpersWithNetworkPath() + { + $parsed = [ + 'parts' => [ + 'path' => '/hello.json', + 'query' => 'foo=bar&page=2&bar=3', + 'scheme' => 'http', + 'user' => 'foo', + 'pass' => 'bar', + 'host' => 'localhost', + 'port' => 8080, + 'fragment' => 'foo', + ], + 'parameters' => [ + 'foo' => 'bar', + 'bar' => '3', + ], + ]; + + $this->assertEquals('//foo:bar@localhost:8080/hello.json?foo=bar&bar=3&page=2#foo', IriHelper::createIri($parsed['parts'], $parsed['parameters'], 'page', 2., UrlGeneratorInterface::NET_PATH)); + + unset($parsed['parts']['scheme']); + + $this->assertEquals('//foo:bar@localhost:8080/hello.json?foo=bar&bar=3&page=2#foo', IriHelper::createIri($parsed['parts'], $parsed['parameters'], 'page', 2., UrlGeneratorInterface::NET_PATH)); + + $parsed['parts']['port'] = 443; + + $this->assertEquals('//foo:bar@localhost:443/hello.json?foo=bar&bar=3&page=2#foo', IriHelper::createIri($parsed['parts'], $parsed['parameters'], 'page', 2., UrlGeneratorInterface::NET_PATH)); + } + public function testParseIriWithInvalidUrl() { $this->expectException(InvalidArgumentException::class); From 90b0151607c53ca77d8c701142ab8948b027e033 Mon Sep 17 00:00:00 2001 From: Vyacheslav Startsev <25704924+vyacheslav-startsev@users.noreply.github.com> Date: Thu, 9 Jul 2020 00:12:16 +0700 Subject: [PATCH 096/160] Lazy console commands --- src/Bridge/Symfony/Bundle/Command/SwaggerCommand.php | 3 ++- src/JsonSchema/Command/JsonSchemaGenerateCommand.php | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Bridge/Symfony/Bundle/Command/SwaggerCommand.php b/src/Bridge/Symfony/Bundle/Command/SwaggerCommand.php index 9c220501e60..e5c8cdfd558 100644 --- a/src/Bridge/Symfony/Bundle/Command/SwaggerCommand.php +++ b/src/Bridge/Symfony/Bundle/Command/SwaggerCommand.php @@ -33,6 +33,8 @@ */ final class SwaggerCommand extends Command { + protected static $defaultName = 'api:openapi:export'; + private $normalizer; private $resourceNameCollectionFactory; private $apiTitle; @@ -67,7 +69,6 @@ public function __construct(NormalizerInterface $normalizer, ResourceNameCollect protected function configure() { $this - ->setName('api:openapi:export') ->setAliases(['api:swagger:export']) ->setDescription('Dump the OpenAPI documentation') ->addOption('yaml', 'y', InputOption::VALUE_NONE, 'Dump the documentation in YAML') diff --git a/src/JsonSchema/Command/JsonSchemaGenerateCommand.php b/src/JsonSchema/Command/JsonSchemaGenerateCommand.php index c322da6ac9b..580e23fb608 100644 --- a/src/JsonSchema/Command/JsonSchemaGenerateCommand.php +++ b/src/JsonSchema/Command/JsonSchemaGenerateCommand.php @@ -31,6 +31,8 @@ */ final class JsonSchemaGenerateCommand extends Command { + protected static $defaultName = 'api:json-schema:generate'; + private $schemaFactory; private $formats; @@ -48,7 +50,6 @@ public function __construct(SchemaFactoryInterface $schemaFactory, array $format protected function configure() { $this - ->setName('api:json-schema:generate') ->setDescription('Generates the JSON Schema for a resource operation.') ->addArgument('resource', InputArgument::REQUIRED, 'The Fully Qualified Class Name (FQCN) of the resource') ->addOption('itemOperation', null, InputOption::VALUE_REQUIRED, 'The item operation') From e937b30fa25735c94e8377694f21d2e2902d748d Mon Sep 17 00:00:00 2001 From: Vyacheslav Startsev <25704924+vyacheslav-startsev@users.noreply.github.com> Date: Thu, 9 Jul 2020 00:24:46 +0700 Subject: [PATCH 097/160] Revert SwaggerCommand --- src/Bridge/Symfony/Bundle/Command/SwaggerCommand.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Bridge/Symfony/Bundle/Command/SwaggerCommand.php b/src/Bridge/Symfony/Bundle/Command/SwaggerCommand.php index e5c8cdfd558..9c220501e60 100644 --- a/src/Bridge/Symfony/Bundle/Command/SwaggerCommand.php +++ b/src/Bridge/Symfony/Bundle/Command/SwaggerCommand.php @@ -33,8 +33,6 @@ */ final class SwaggerCommand extends Command { - protected static $defaultName = 'api:openapi:export'; - private $normalizer; private $resourceNameCollectionFactory; private $apiTitle; @@ -69,6 +67,7 @@ public function __construct(NormalizerInterface $normalizer, ResourceNameCollect protected function configure() { $this + ->setName('api:openapi:export') ->setAliases(['api:swagger:export']) ->setDescription('Dump the OpenAPI documentation') ->addOption('yaml', 'y', InputOption::VALUE_NONE, 'Dump the documentation in YAML') From ad17f0a52ffefa1a8eafe243f171c6f8da4d92f1 Mon Sep 17 00:00:00 2001 From: Vyacheslav Startsev <25704924+vyacheslav-startsev@users.noreply.github.com> Date: Fri, 10 Jul 2020 23:20:10 +0700 Subject: [PATCH 098/160] Fix code style --- features/bootstrap/DoctrineContext.php | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/features/bootstrap/DoctrineContext.php b/features/bootstrap/DoctrineContext.php index a8610a3a500..3901c90bc66 100644 --- a/features/bootstrap/DoctrineContext.php +++ b/features/bootstrap/DoctrineContext.php @@ -121,7 +121,6 @@ use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\MaxDepthDummy; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\NetworkPathDummy; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\NetworkPathRelationDummy; -use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Node; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Order; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Person; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\PersonToPet; @@ -1542,7 +1541,7 @@ public function thereAreDummyMercureObjects(int $nb) { for ($i = 1; $i <= $nb; ++$i) { $relatedDummy = $this->buildRelatedDummy(); - $relatedDummy->setName('RelatedDummy #' . $i); + $relatedDummy->setName('RelatedDummy #'.$i); $dummyMercure = $this->buildDummyMercure(); $dummyMercure->name = "Dummy Mercure #$i"; @@ -1974,7 +1973,6 @@ private function buildConvertedRelated() private function buildDummyMercure() { return $this->isOrm() ? new DummyMercure() : new DummyMercureDocument(); - } /** From 0be97988871cd495bb6433817c9a7a220a3447e4 Mon Sep 17 00:00:00 2001 From: Ollie Harridge Date: Sat, 18 Jul 2020 13:11:23 +0100 Subject: [PATCH 099/160] Handling GraphQL errors (#3632) Co-authored-by: Ollie Harridge Co-authored-by: Alan Poulain --- CHANGELOG.md | 1 + features/bootstrap/DoctrineContext.php | 4 +-- .../ApiPlatformExtension.php | 3 ++ .../Bundle/Resources/config/graphql.xml | 5 +++ src/GraphQl/Action/EntrypointAction.php | 7 +++- src/GraphQl/Error/ErrorHandler.php | 32 +++++++++++++++++++ src/GraphQl/Error/ErrorHandlerInterface.php | 32 +++++++++++++++++++ .../ApiPlatformExtensionTest.php | 9 ++++++ tests/GraphQl/Action/EntrypointActionTest.php | 5 ++- 9 files changed, 93 insertions(+), 5 deletions(-) create mode 100644 src/GraphQl/Error/ErrorHandler.php create mode 100644 src/GraphQl/Error/ErrorHandlerInterface.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 61a6a5186ff..f9bd7a2239f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ * GraphQL: Subscription support with Mercure (#3321) * GraphQL: Allow to format GraphQL errors based on exceptions (#3063) * GraphQL: Add page-based pagination (#3175, #3517) +* GraphQL: Errors thrown from the GraphQL library can now be handled (#3632) * GraphQL: Possibility to add a custom description for queries, mutations and subscriptions (#3477, #3514) * GraphQL: Support for field name conversion (serialized name) (#3455, #3516) * GraphQL: **BC** `operation` is now `operationName` to follow the standard (#3568) diff --git a/features/bootstrap/DoctrineContext.php b/features/bootstrap/DoctrineContext.php index a8610a3a500..3901c90bc66 100644 --- a/features/bootstrap/DoctrineContext.php +++ b/features/bootstrap/DoctrineContext.php @@ -121,7 +121,6 @@ use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\MaxDepthDummy; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\NetworkPathDummy; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\NetworkPathRelationDummy; -use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Node; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Order; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Person; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\PersonToPet; @@ -1542,7 +1541,7 @@ public function thereAreDummyMercureObjects(int $nb) { for ($i = 1; $i <= $nb; ++$i) { $relatedDummy = $this->buildRelatedDummy(); - $relatedDummy->setName('RelatedDummy #' . $i); + $relatedDummy->setName('RelatedDummy #'.$i); $dummyMercure = $this->buildDummyMercure(); $dummyMercure->name = "Dummy Mercure #$i"; @@ -1974,7 +1973,6 @@ private function buildConvertedRelated() private function buildDummyMercure() { return $this->isOrm() ? new DummyMercure() : new DummyMercureDocument(); - } /** diff --git a/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index 0b1db78d2c8..42d7faf8715 100644 --- a/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -31,6 +31,7 @@ use ApiPlatform\Core\DataProvider\ItemDataProviderInterface; use ApiPlatform\Core\DataProvider\SubresourceDataProviderInterface; use ApiPlatform\Core\DataTransformer\DataTransformerInterface; +use ApiPlatform\Core\GraphQl\Error\ErrorHandlerInterface; use ApiPlatform\Core\GraphQl\Resolver\MutationResolverInterface; use ApiPlatform\Core\GraphQl\Resolver\QueryCollectionResolverInterface; use ApiPlatform\Core\GraphQl\Resolver\QueryItemResolverInterface; @@ -463,6 +464,8 @@ private function registerGraphQlConfiguration(ContainerBuilder $container, array ->addTag('api_platform.graphql.mutation_resolver'); $container->registerForAutoconfiguration(GraphQlTypeInterface::class) ->addTag('api_platform.graphql.type'); + $container->registerForAutoconfiguration(ErrorHandlerInterface::class) + ->addTag('api_platform.graphql.error_handler'); } private function registerLegacyBundlesConfiguration(ContainerBuilder $container, array $config, XmlFileLoader $loader): void diff --git a/src/Bridge/Symfony/Bundle/Resources/config/graphql.xml b/src/Bridge/Symfony/Bundle/Resources/config/graphql.xml index a5ca2c709ea..48638b0e99a 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/graphql.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/graphql.xml @@ -179,6 +179,7 @@ + %kernel.debug% %api_platform.graphql.graphiql.enabled% %api_platform.graphql.graphql_playground.enabled% @@ -199,6 +200,10 @@ %api_platform.title% + + + + diff --git a/src/GraphQl/Action/EntrypointAction.php b/src/GraphQl/Action/EntrypointAction.php index 825fb601ed2..cd40b92c7f5 100644 --- a/src/GraphQl/Action/EntrypointAction.php +++ b/src/GraphQl/Action/EntrypointAction.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Core\GraphQl\Action; +use ApiPlatform\Core\GraphQl\Error\ErrorHandlerInterface; use ApiPlatform\Core\GraphQl\ExecutorInterface; use ApiPlatform\Core\GraphQl\Type\SchemaBuilderInterface; use GraphQL\Error\Debug; @@ -38,18 +39,20 @@ final class EntrypointAction private $graphiQlAction; private $graphQlPlaygroundAction; private $normalizer; + private $errorHandler; private $debug; private $graphiqlEnabled; private $graphQlPlaygroundEnabled; private $defaultIde; - public function __construct(SchemaBuilderInterface $schemaBuilder, ExecutorInterface $executor, GraphiQlAction $graphiQlAction, GraphQlPlaygroundAction $graphQlPlaygroundAction, NormalizerInterface $normalizer, bool $debug = false, bool $graphiqlEnabled = false, bool $graphQlPlaygroundEnabled = false, $defaultIde = false) + public function __construct(SchemaBuilderInterface $schemaBuilder, ExecutorInterface $executor, GraphiQlAction $graphiQlAction, GraphQlPlaygroundAction $graphQlPlaygroundAction, NormalizerInterface $normalizer, ErrorHandlerInterface $errorHandler, bool $debug = false, bool $graphiqlEnabled = false, bool $graphQlPlaygroundEnabled = false, $defaultIde = false) { $this->schemaBuilder = $schemaBuilder; $this->executor = $executor; $this->graphiQlAction = $graphiQlAction; $this->graphQlPlaygroundAction = $graphQlPlaygroundAction; $this->normalizer = $normalizer; + $this->errorHandler = $errorHandler; $this->debug = $debug ? Debug::INCLUDE_DEBUG_MESSAGE | Debug::INCLUDE_TRACE : false; $this->graphiqlEnabled = $graphiqlEnabled; $this->graphQlPlaygroundEnabled = $graphQlPlaygroundEnabled; @@ -76,9 +79,11 @@ public function __invoke(Request $request): Response $executionResult = $this->executor ->executeQuery($this->schemaBuilder->getSchema(), $query, null, null, $variables, $operationName) + ->setErrorsHandler($this->errorHandler) ->setErrorFormatter([$this->normalizer, 'normalize']); } catch (\Exception $exception) { $executionResult = (new ExecutionResult(null, [new Error($exception->getMessage(), null, null, null, null, $exception)])) + ->setErrorsHandler($this->errorHandler) ->setErrorFormatter([$this->normalizer, 'normalize']); } diff --git a/src/GraphQl/Error/ErrorHandler.php b/src/GraphQl/Error/ErrorHandler.php new file mode 100644 index 00000000000..778c42e1ae8 --- /dev/null +++ b/src/GraphQl/Error/ErrorHandler.php @@ -0,0 +1,32 @@ + + * + * 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\GraphQl\Error; + +/** + * Handles the errors thrown by the GraphQL library by applying the formatter to them (default behavior). + * + * @experimental + * + * @author Ollie Harridge + */ +class ErrorHandler implements ErrorHandlerInterface +{ + /** + * {@inheritdoc} + */ + public function __invoke(array $errors, callable $formatter): array + { + return array_map($formatter, $errors); + } +} diff --git a/src/GraphQl/Error/ErrorHandlerInterface.php b/src/GraphQl/Error/ErrorHandlerInterface.php new file mode 100644 index 00000000000..4140df1d413 --- /dev/null +++ b/src/GraphQl/Error/ErrorHandlerInterface.php @@ -0,0 +1,32 @@ + + * + * 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\GraphQl\Error; + +use GraphQL\Error\Error; + +/** + * Handles the errors thrown by the GraphQL library. + * It is responsible for applying the formatter to the errors and can be used for filtering or logging them. + * + * @experimental + * + * @author Ollie Harridge + */ +interface ErrorHandlerInterface +{ + /** + * @param Error[] $errors + */ + public function __invoke(array $errors, callable $formatter): array; +} diff --git a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php index 9ada175bd00..4e5293d2416 100644 --- a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php +++ b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php @@ -65,6 +65,7 @@ use ApiPlatform\Core\DataTransformer\DataTransformerInterface; use ApiPlatform\Core\Exception\FilterValidationException; use ApiPlatform\Core\Exception\InvalidArgumentException; +use ApiPlatform\Core\GraphQl\Error\ErrorHandlerInterface; use ApiPlatform\Core\GraphQl\Resolver\MutationResolverInterface; use ApiPlatform\Core\GraphQl\Resolver\QueryCollectionResolverInterface; use ApiPlatform\Core\GraphQl\Resolver\QueryItemResolverInterface; @@ -342,6 +343,7 @@ public function testDisableGraphQl() $containerBuilderProphecy->setDefinition('api_platform.graphql.action.entrypoint', Argument::type(Definition::class))->shouldNotBeCalled(); $containerBuilderProphecy->setDefinition('api_platform.graphql.action.graphiql', Argument::type(Definition::class))->shouldNotBeCalled(); $containerBuilderProphecy->setDefinition('api_platform.graphql.action.graphql_playground', Argument::type(Definition::class))->shouldNotBeCalled(); + $containerBuilderProphecy->setDefinition('api_platform.graphql.error_handler', Argument::type(Definition::class))->shouldNotBeCalled(); $containerBuilderProphecy->setDefinition('api_platform.graphql.resolver.factory.collection', Argument::type(Definition::class))->shouldNotBeCalled(); $containerBuilderProphecy->setDefinition('api_platform.graphql.resolver.factory.item_mutation', Argument::type(Definition::class))->shouldNotBeCalled(); $containerBuilderProphecy->setDefinition('api_platform.graphql.resolver.factory.item_subscription', Argument::type(Definition::class))->shouldNotBeCalled(); @@ -392,6 +394,8 @@ public function testDisableGraphQl() $containerBuilderProphecy->setParameter('api_platform.graphql.graphql_playground.enabled', false)->shouldBeCalled(); $containerBuilderProphecy->registerForAutoconfiguration(GraphQlTypeInterface::class)->shouldNotBeCalled(); $this->childDefinitionProphecy->addTag('api_platform.graphql.type')->shouldNotBeCalled(); + $containerBuilderProphecy->registerForAutoconfiguration(ErrorHandlerInterface::class)->shouldNotBeCalled(); + $this->childDefinitionProphecy->addTag('api_platform.graphql.error_handler')->shouldNotBeCalled(); $containerBuilderProphecy->registerForAutoconfiguration(QueryItemResolverInterface::class)->shouldNotBeCalled(); $containerBuilderProphecy->registerForAutoconfiguration(QueryCollectionResolverInterface::class)->shouldNotBeCalled(); $this->childDefinitionProphecy->addTag('api_platform.graphql.query_resolver')->shouldNotBeCalled(); @@ -1077,6 +1081,10 @@ private function getBaseContainerBuilderProphecy(array $doctrineIntegrationsToLo ->willReturn($this->childDefinitionProphecy)->shouldBeCalledTimes(1); $this->childDefinitionProphecy->addTag('api_platform.graphql.type')->shouldBeCalledTimes(1); + $containerBuilderProphecy->registerForAutoconfiguration(ErrorHandlerInterface::class) + ->willReturn($this->childDefinitionProphecy)->shouldBeCalledTimes(1); + $this->childDefinitionProphecy->addTag('api_platform.graphql.error_handler')->shouldBeCalledTimes(1); + $containerBuilderProphecy->registerForAutoconfiguration(QueryItemResolverInterface::class) ->willReturn($this->childDefinitionProphecy)->shouldBeCalledTimes(1); $containerBuilderProphecy->registerForAutoconfiguration(QueryCollectionResolverInterface::class) @@ -1195,6 +1203,7 @@ private function getBaseContainerBuilderProphecy(array $doctrineIntegrationsToLo 'api_platform.graphql.action.entrypoint', 'api_platform.graphql.action.graphiql', 'api_platform.graphql.action.graphql_playground', + 'api_platform.graphql.error_handler', 'api_platform.graphql.executor', 'api_platform.graphql.type_builder', 'api_platform.graphql.fields_builder', diff --git a/tests/GraphQl/Action/EntrypointActionTest.php b/tests/GraphQl/Action/EntrypointActionTest.php index e0892e3bff6..c1061d6110b 100644 --- a/tests/GraphQl/Action/EntrypointActionTest.php +++ b/tests/GraphQl/Action/EntrypointActionTest.php @@ -16,6 +16,7 @@ use ApiPlatform\Core\GraphQl\Action\EntrypointAction; use ApiPlatform\Core\GraphQl\Action\GraphiQlAction; use ApiPlatform\Core\GraphQl\Action\GraphQlPlaygroundAction; +use ApiPlatform\Core\GraphQl\Error\ErrorHandler; use ApiPlatform\Core\GraphQl\ExecutorInterface; use ApiPlatform\Core\GraphQl\Serializer\Exception\ErrorNormalizer; use ApiPlatform\Core\GraphQl\Serializer\Exception\HttpExceptionNormalizer; @@ -235,10 +236,12 @@ private function getEntrypointAction(array $variables = ['graphqlVariable']): En new HttpExceptionNormalizer(), new ErrorNormalizer(), ]); + $errorHandler = new ErrorHandler(); $executionResultProphecy = $this->prophesize(ExecutionResult::class); $executionResultProphecy->toArray(false)->willReturn(['GraphQL']); $executionResultProphecy->setErrorFormatter([$normalizer, 'normalize'])->willReturn($executionResultProphecy); + $executionResultProphecy->setErrorsHandler($errorHandler)->willReturn($executionResultProphecy); $executorProphecy = $this->prophesize(ExecutorInterface::class); $executorProphecy->executeQuery(Argument::is($schema->reveal()), 'graphqlQuery', null, null, $variables, 'graphqlOperationName')->willReturn($executionResultProphecy->reveal()); @@ -248,6 +251,6 @@ private function getEntrypointAction(array $variables = ['graphqlVariable']): En $graphiQlAction = new GraphiQlAction($twigProphecy->reveal(), $routerProphecy->reveal(), true); $graphQlPlaygroundAction = new GraphQlPlaygroundAction($twigProphecy->reveal(), $routerProphecy->reveal(), true); - return new EntrypointAction($schemaBuilderProphecy->reveal(), $executorProphecy->reveal(), $graphiQlAction, $graphQlPlaygroundAction, $normalizer, false, true, true, 'graphiql'); + return new EntrypointAction($schemaBuilderProphecy->reveal(), $executorProphecy->reveal(), $graphiQlAction, $graphQlPlaygroundAction, $normalizer, $errorHandler, false, true, true, 'graphiql'); } } From 5109bf12b98902569462437f5eaf6d00d9d0a120 Mon Sep 17 00:00:00 2001 From: Alan Poulain Date: Sun, 19 Jul 2020 13:08:39 +0200 Subject: [PATCH 100/160] [GraphQL] Make the error handler final (#3643) --- src/GraphQl/Error/ErrorHandler.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/GraphQl/Error/ErrorHandler.php b/src/GraphQl/Error/ErrorHandler.php index 778c42e1ae8..955445cd982 100644 --- a/src/GraphQl/Error/ErrorHandler.php +++ b/src/GraphQl/Error/ErrorHandler.php @@ -20,7 +20,7 @@ * * @author Ollie Harridge */ -class ErrorHandler implements ErrorHandlerInterface +final class ErrorHandler implements ErrorHandlerInterface { /** * {@inheritdoc} From 2ffee41f8c64979bb318d5a10f0fcaa7694bf666 Mon Sep 17 00:00:00 2001 From: Alan Poulain Date: Thu, 23 Jul 2020 15:09:13 +0200 Subject: [PATCH 101/160] [GraphQL] No BC for types (#3654) --- src/GraphQl/Type/Definition/IterableType.php | 55 ++++++++++++++------ src/GraphQl/Type/Definition/UploadType.php | 34 +++++++++--- 2 files changed, 66 insertions(+), 23 deletions(-) diff --git a/src/GraphQl/Type/Definition/IterableType.php b/src/GraphQl/Type/Definition/IterableType.php index bf488c58667..30f66d73f98 100644 --- a/src/GraphQl/Type/Definition/IterableType.php +++ b/src/GraphQl/Type/Definition/IterableType.php @@ -26,6 +26,44 @@ use GraphQL\Type\Definition\ScalarType; use GraphQL\Utils\Utils; +if (\PHP_VERSION_ID >= 70200) { + trait IterableTypeParseLiteralTrait + { + /** + * {@inheritdoc} + * + * @param ObjectValueNode|ListValueNode|IntValueNode|FloatValueNode|StringValueNode|BooleanValueNode|NullValueNode $valueNode + */ + public function parseLiteral(/*Node */$valueNode, ?array $variables = null) + { + if ($valueNode instanceof ObjectValueNode || $valueNode instanceof ListValueNode) { + return $this->parseIterableLiteral($valueNode); + } + + // Intentionally without message, as all information already in wrapped Exception + throw new \Exception(); + } + } +} else { + trait IterableTypeParseLiteralTrait + { + /** + * {@inheritdoc} + * + * @param ObjectValueNode|ListValueNode|IntValueNode|FloatValueNode|StringValueNode|BooleanValueNode|NullValueNode $valueNode + */ + public function parseLiteral(Node $valueNode, ?array $variables = null) + { + if ($valueNode instanceof ObjectValueNode || $valueNode instanceof ListValueNode) { + return $this->parseIterableLiteral($valueNode); + } + + // Intentionally without message, as all information already in wrapped Exception + throw new \Exception(); + } + } +} + /** * Represents an iterable type. * @@ -35,6 +73,8 @@ */ final class IterableType extends ScalarType implements TypeInterface { + use IterableTypeParseLiteralTrait; + public function __construct() { $this->name = 'Iterable'; @@ -72,21 +112,6 @@ public function parseValue($value) return $value; } - /** - * {@inheritdoc} - * - * @param ObjectValueNode|ListValueNode|IntValueNode|FloatValueNode|StringValueNode|BooleanValueNode|NullValueNode $valueNode - */ - public function parseLiteral(Node $valueNode, ?array $variables = null) - { - if ($valueNode instanceof ObjectValueNode || $valueNode instanceof ListValueNode) { - return $this->parseIterableLiteral($valueNode); - } - - // Intentionally without message, as all information already in wrapped Exception - throw new \Exception(); - } - /** * @param StringValueNode|BooleanValueNode|IntValueNode|FloatValueNode|ObjectValueNode|ListValueNode|ValueNode $valueNode */ diff --git a/src/GraphQl/Type/Definition/UploadType.php b/src/GraphQl/Type/Definition/UploadType.php index 36f3b715653..af804510aea 100644 --- a/src/GraphQl/Type/Definition/UploadType.php +++ b/src/GraphQl/Type/Definition/UploadType.php @@ -19,6 +19,30 @@ use GraphQL\Utils\Utils; use Symfony\Component\HttpFoundation\File\UploadedFile; +if (\PHP_VERSION_ID >= 70200) { + trait UploadTypeParseLiteralTrait + { + /** + * {@inheritdoc} + */ + public function parseLiteral(/*Node */$valueNode, array $variables = null) + { + throw new Error('`Upload` cannot be hardcoded in query, be sure to conform to GraphQL multipart request specification.', $valueNode); + } + } +} else { + trait UploadTypeParseLiteralTrait + { + /** + * {@inheritdoc} + */ + public function parseLiteral(Node $valueNode, array $variables = null) + { + throw new Error('`Upload` cannot be hardcoded in query, be sure to conform to GraphQL multipart request specification.', $valueNode); + } + } +} + /** * Represents an upload type. * @@ -26,6 +50,8 @@ */ final class UploadType extends ScalarType implements TypeInterface { + use UploadTypeParseLiteralTrait; + public function __construct() { $this->name = 'Upload'; @@ -58,12 +84,4 @@ public function parseValue($value): UploadedFile return $value; } - - /** - * {@inheritdoc} - */ - public function parseLiteral(Node $valueNode, array $variables = null) - { - throw new Error('`Upload` cannot be hardcoded in query, be sure to conform to GraphQL multipart request specification.', $valueNode); - } } From 2a221664cb35a7c44fd7579755021a080a208e49 Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 23 Jul 2020 15:57:57 +0200 Subject: [PATCH 102/160] Fix merge --- src/GraphQl/Action/EntrypointAction.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/GraphQl/Action/EntrypointAction.php b/src/GraphQl/Action/EntrypointAction.php index 6f983418233..27d1e99e6ee 100644 --- a/src/GraphQl/Action/EntrypointAction.php +++ b/src/GraphQl/Action/EntrypointAction.php @@ -101,17 +101,17 @@ public function __invoke(Request $request): Response private function parseRequest(Request $request): array { $query = $request->query->get('query'); - $operation = $request->query->get('operation'); + $operationName = $request->query->get('operationName'); if ($variables = $request->query->get('variables') ?: []) { $variables = $this->decodeVariables($variables); } if (!$request->isMethod('POST')) { - return [$query, $operation, $variables]; + return [$query, $operationName, $variables]; } if ('json' === $request->getContentType()) { - return $this->parseData($query, $operation, $variables, $request->getContent()); + return $this->parseData($query, $operationName, $variables, $request->getContent()); } if ('graphql' === $request->getContentType()) { @@ -119,10 +119,10 @@ private function parseRequest(Request $request): array } if ('multipart' === $request->getContentType()) { - return $this->parseMultipartRequest($query, $operation, $variables, $request->request->all(), $request->files->all()); + return $this->parseMultipartRequest($query, $operationName, $variables, $request->request->all(), $request->files->all()); } - return [$query, $operation, $variables]; + return [$query, $operationName, $variables]; } /** From 2dc666c5390d4c2ba92708a2868b073e1493fa4a Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Thu, 23 Jul 2020 16:32:13 +0200 Subject: [PATCH 103/160] OpenApi v3 refactor (#3407) * Open API v3 refactor * Fix external properties * Fix paths api * Opti * @flug's review Co-Authored-By: Flug * Fix tests * Fix tests * fix tests * Bump serializer min version * Fix some comments * Fix some comments * Fix some comments * Fix some tests * Remove operationRef * openapi: add subresource support * openapi: fix multiple formats * Fix tests Co-authored-by: Flug --- composer.json | 2 +- .../Symfony/Bundle/Command/OpenApiCommand.php | 93 +++ .../Symfony/Bundle/Command/SwaggerCommand.php | 9 +- .../ApiPlatformExtension.php | 2 + .../DependencyInjection/Configuration.php | 3 +- .../Bundle/Resources/config/data_provider.xml | 11 + .../Bundle/Resources/config/openapi.xml | 57 ++ src/DataProvider/PaginationOptions.php | 64 ++ .../Action/DocumentationAction.php | 20 +- src/Documentation/Documentation.php | 2 +- src/Documentation/DocumentationInterface.php | 21 + src/OpenApi/Factory/OpenApiFactory.php | 448 ++++++++++++ .../Factory/OpenApiFactoryInterface.php | 24 + src/OpenApi/Model/Components.php | 159 +++++ src/OpenApi/Model/Contact.php | 69 ++ src/OpenApi/Model/Encoding.php | 99 +++ src/OpenApi/Model/ExtensionTrait.php | 36 + src/OpenApi/Model/ExternalDocumentation.php | 54 ++ src/OpenApi/Model/Info.php | 114 +++ src/OpenApi/Model/License.php | 54 ++ src/OpenApi/Model/Link.php | 99 +++ src/OpenApi/Model/MediaType.php | 84 +++ src/OpenApi/Model/OAuthFlow.php | 84 +++ src/OpenApi/Model/OAuthFlows.php | 84 +++ src/OpenApi/Model/Operation.php | 211 ++++++ src/OpenApi/Model/Parameter.php | 227 ++++++ src/OpenApi/Model/PathItem.php | 220 ++++++ src/OpenApi/Model/Paths.php | 34 + src/OpenApi/Model/RequestBody.php | 69 ++ src/OpenApi/Model/Response.php | 84 +++ src/OpenApi/Model/Schema.php | 171 +++++ src/OpenApi/Model/SecurityScheme.php | 144 ++++ src/OpenApi/Model/Server.php | 69 ++ src/OpenApi/OpenApi.php | 152 ++++ src/OpenApi/Options.php | 99 +++ src/OpenApi/Serializer/OpenApiNormalizer.php | 91 +++ .../Serializer/DocumentationNormalizer.php | 1 + .../Bundle/Command/OpenApiCommandTest.php | 112 +++ .../Bundle/Command/SwaggerCommandTest.php | 26 +- .../ApiPlatformExtensionTest.php | 15 + .../DependencyInjection/ConfigurationTest.php | 1 + .../Action/DocumentationActionTest.php | 54 +- tests/OpenApi/Factory/OpenApiFactoryTest.php | 672 ++++++++++++++++++ .../Serializer/OpenApiNormalizerTest.php | 158 ++++ 44 files changed, 4275 insertions(+), 27 deletions(-) create mode 100644 src/Bridge/Symfony/Bundle/Command/OpenApiCommand.php create mode 100644 src/Bridge/Symfony/Bundle/Resources/config/openapi.xml create mode 100644 src/DataProvider/PaginationOptions.php create mode 100644 src/Documentation/DocumentationInterface.php create mode 100644 src/OpenApi/Factory/OpenApiFactory.php create mode 100644 src/OpenApi/Factory/OpenApiFactoryInterface.php create mode 100644 src/OpenApi/Model/Components.php create mode 100644 src/OpenApi/Model/Contact.php create mode 100644 src/OpenApi/Model/Encoding.php create mode 100644 src/OpenApi/Model/ExtensionTrait.php create mode 100644 src/OpenApi/Model/ExternalDocumentation.php create mode 100644 src/OpenApi/Model/Info.php create mode 100644 src/OpenApi/Model/License.php create mode 100644 src/OpenApi/Model/Link.php create mode 100644 src/OpenApi/Model/MediaType.php create mode 100644 src/OpenApi/Model/OAuthFlow.php create mode 100644 src/OpenApi/Model/OAuthFlows.php create mode 100644 src/OpenApi/Model/Operation.php create mode 100644 src/OpenApi/Model/Parameter.php create mode 100644 src/OpenApi/Model/PathItem.php create mode 100644 src/OpenApi/Model/Paths.php create mode 100644 src/OpenApi/Model/RequestBody.php create mode 100644 src/OpenApi/Model/Response.php create mode 100644 src/OpenApi/Model/Schema.php create mode 100644 src/OpenApi/Model/SecurityScheme.php create mode 100644 src/OpenApi/Model/Server.php create mode 100644 src/OpenApi/OpenApi.php create mode 100644 src/OpenApi/Options.php create mode 100644 src/OpenApi/Serializer/OpenApiNormalizer.php create mode 100644 tests/Bridge/Symfony/Bundle/Command/OpenApiCommandTest.php create mode 100644 tests/OpenApi/Factory/OpenApiFactoryTest.php create mode 100644 tests/OpenApi/Serializer/OpenApiNormalizerTest.php diff --git a/composer.json b/composer.json index d5de7dbb567..006dff9d9a1 100644 --- a/composer.json +++ b/composer.json @@ -22,7 +22,7 @@ "symfony/http-kernel": "^4.3.7 || ^5.0", "symfony/property-access": "^3.4 || ^4.0 || ^5.0", "symfony/property-info": "^3.4 || ^4.0 || ^5.0", - "symfony/serializer": "^4.3 || ^5.0", + "symfony/serializer": "^4.4 || ^5.0", "symfony/web-link": "^4.1 || ^5.0", "willdurand/negotiation": "^2.0.3" }, diff --git a/src/Bridge/Symfony/Bundle/Command/OpenApiCommand.php b/src/Bridge/Symfony/Bundle/Command/OpenApiCommand.php new file mode 100644 index 00000000000..6bc69f52a86 --- /dev/null +++ b/src/Bridge/Symfony/Bundle/Command/OpenApiCommand.php @@ -0,0 +1,93 @@ + + * + * 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\Bridge\Symfony\Bundle\Command; + +use ApiPlatform\Core\OpenApi\Factory\OpenApiFactoryInterface; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\ArrayInput; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; +use Symfony\Component\Yaml\Yaml; + +/** + * Dumps Open API documentation. + */ +final class OpenApiCommand extends Command +{ + private $openApiFactory; + private $normalizer; + + public function __construct(OpenApiFactoryInterface $openApiFactory, NormalizerInterface $normalizer) + { + parent::__construct(); + $this->openApiFactory = $openApiFactory; + $this->normalizer = $normalizer; + } + + /** + * {@inheritdoc} + */ + protected function configure() + { + $this + ->setName('api:openapi:export') + ->setDescription('Dump the Open API documentation') + ->addOption('yaml', 'y', InputOption::VALUE_NONE, 'Dump the documentation in YAML') + ->addOption('output', 'o', InputOption::VALUE_OPTIONAL, 'Write output to file') + ->addOption('spec-version', null, InputOption::VALUE_OPTIONAL, 'Open API version to use (2 or 3) (2 is deprecated)', 3) + ->addOption('api-gateway', null, InputOption::VALUE_NONE, 'Enable the Amazon API Gateway compatibility mode'); + } + + /** + * {@inheritdoc} + */ + protected function execute(InputInterface $input, OutputInterface $output) + { + // Backwards compatibility + if (2 === $specVersion = (int) $input->getOption('spec-version')) { + $command = $this->getApplication()->find('api:swagger:export'); + + return $command->run(new ArrayInput([ + 'command' => 'api:swagger:export', + '--spec-version' => $specVersion, + '--yaml' => $input->getOption('yaml'), + '--output' => $input->getOption('output'), + '--api-gateway' => $input->getOption('api-gateway'), + ]), $output); + } + + $filesystem = new Filesystem(); + $io = new SymfonyStyle($input, $output); + $data = $this->normalizer->normalize($this->openApiFactory->__invoke(), 'json'); + $content = $input->getOption('yaml') + ? Yaml::dump($data, 10, 2, Yaml::DUMP_OBJECT_AS_MAP | Yaml::DUMP_EMPTY_ARRAY_AS_SEQUENCE | Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK) + : (json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) ?: ''); + + $filename = $input->getOption('output'); + if ($filename && \is_string($filename)) { + $filesystem->dumpFile($filename, $content); + $io->success(sprintf('Data written to %s.', $filename)); + + return 0; + } + + $output->writeln($content); + + return 0; + } +} diff --git a/src/Bridge/Symfony/Bundle/Command/SwaggerCommand.php b/src/Bridge/Symfony/Bundle/Command/SwaggerCommand.php index 9c220501e60..02dfbe45006 100644 --- a/src/Bridge/Symfony/Bundle/Command/SwaggerCommand.php +++ b/src/Bridge/Symfony/Bundle/Command/SwaggerCommand.php @@ -67,9 +67,8 @@ public function __construct(NormalizerInterface $normalizer, ResourceNameCollect protected function configure() { $this - ->setName('api:openapi:export') - ->setAliases(['api:swagger:export']) - ->setDescription('Dump the OpenAPI documentation') + ->setName('api:swagger:export') + ->setDescription('Dump the Swagger v2 documentation') ->addOption('yaml', 'y', InputOption::VALUE_NONE, 'Dump the documentation in YAML') ->addOption('spec-version', null, InputOption::VALUE_OPTIONAL, sprintf('OpenAPI version to use (%s)', implode(' or ', $this->swaggerVersions)), $this->swaggerVersions[0] ?? 2) ->addOption('output', 'o', InputOption::VALUE_OPTIONAL, 'Write output to file') @@ -90,6 +89,10 @@ protected function execute(InputInterface $input, OutputInterface $output) throw new InvalidOptionException(sprintf('This tool only supports versions %s of the OpenAPI specification ("%s" given).', implode(', ', $this->swaggerVersions), $version)); } + if (3 === (int) $version) { + @trigger_error('The command "api:swagger:export" is deprecated for the spec version 3 use "api:openapi:export".', E_USER_DEPRECATED); + } + $documentation = new Documentation($this->resourceNameCollectionFactory->create(), $this->apiTitle, $this->apiDescription, $this->apiVersion, $this->apiFormats); $data = $this->normalizer->normalize($documentation, DocumentationNormalizer::FORMAT, ['spec_version' => (int) $version, ApiGatewayNormalizer::API_GATEWAY => $input->getOption('api-gateway')]); $content = $input->getOption('yaml') diff --git a/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index 42d7faf8715..4a33e424ae3 100644 --- a/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -365,6 +365,7 @@ private function registerOAuthConfiguration(ContainerBuilder $container, array $ $container->setParameter('api_platform.oauth.flow', $config['oauth']['flow']); $container->setParameter('api_platform.oauth.tokenUrl', $config['oauth']['tokenUrl']); $container->setParameter('api_platform.oauth.authorizationUrl', $config['oauth']['authorizationUrl']); + $container->setParameter('api_platform.oauth.refreshUrl', $config['oauth']['refreshUrl']); $container->setParameter('api_platform.oauth.scopes', $config['oauth']['scopes']); } @@ -381,6 +382,7 @@ private function registerSwaggerConfiguration(ContainerBuilder $container, array $loader->load('json_schema.xml'); $loader->load('swagger.xml'); + $loader->load('openapi.xml'); $loader->load('swagger-ui.xml'); if (!$config['enable_swagger_ui'] && !$config['enable_re_doc']) { diff --git a/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php b/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php index 536d15ad886..51ca4471e2b 100644 --- a/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php +++ b/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php @@ -249,6 +249,7 @@ private function addOAuthSection(ArrayNodeDefinition $rootNode): void ->scalarNode('flow')->defaultValue('application')->info('The oauth flow grant type.')->end() ->scalarNode('tokenUrl')->defaultValue('/oauth/v2/token')->info('The oauth token url.')->end() ->scalarNode('authorizationUrl')->defaultValue('/oauth/v2/auth')->info('The oauth authentication url.')->end() + ->scalarNode('refreshUrl')->defaultValue('/oauth/v2/refresh')->info('The oauth refresh url.')->end() ->arrayNode('scopes') ->prototype('scalar')->end() ->end() @@ -296,7 +297,7 @@ private function addSwaggerSection(ArrayNodeDefinition $rootNode): void ->addDefaultsIfNotSet() ->children() ->arrayNode('versions') - ->info('The active versions of OpenAPI to be exported or used in the swagger_ui. The first value is the default.') + ->info('The active versions of Open API to be exported or used in the swagger_ui. The first value is the default.') ->defaultValue($defaultVersions) ->beforeNormalization() ->always(static function ($v) { diff --git a/src/Bridge/Symfony/Bundle/Resources/config/data_provider.xml b/src/Bridge/Symfony/Bundle/Resources/config/data_provider.xml index 471da830649..a838e119689 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/data_provider.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/data_provider.xml @@ -33,6 +33,17 @@ %api_platform.graphql.collection.pagination% + + + %api_platform.collection.pagination.enabled% + %api_platform.collection.pagination.page_parameter_name% + %api_platform.collection.pagination.client_items_per_page% + %api_platform.collection.pagination.items_per_page_parameter_name% + %api_platform.collection.pagination.client_enabled% + %api_platform.collection.pagination.enabled_parameter_name% + + + diff --git a/src/Bridge/Symfony/Bundle/Resources/config/openapi.xml b/src/Bridge/Symfony/Bundle/Resources/config/openapi.xml new file mode 100644 index 00000000000..03bda2f24da --- /dev/null +++ b/src/Bridge/Symfony/Bundle/Resources/config/openapi.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + %api_platform.title% + %api_platform.description% + %api_platform.version% + %api_platform.oauth.enabled% + %api_platform.oauth.type% + %api_platform.oauth.flow% + %api_platform.oauth.tokenUrl% + %api_platform.oauth.authorizationUrl% + %api_platform.oauth.refreshUrl% + %api_platform.oauth.scopes% + %api_platform.swagger.api_keys% + + + + + + + + + + + + + + %api_platform.formats% + + + + + + + + + + + + + + + + + + diff --git a/src/DataProvider/PaginationOptions.php b/src/DataProvider/PaginationOptions.php new file mode 100644 index 00000000000..1db7646943f --- /dev/null +++ b/src/DataProvider/PaginationOptions.php @@ -0,0 +1,64 @@ + + * + * 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\DataProvider; + +final class PaginationOptions +{ + private $paginationEnabled; + private $paginationPageParameterName; + private $clientItemsPerPage; + private $itemsPerPageParameterName; + private $paginationClientEnabled; + private $paginationClientEnabledParameterName; + + public function __construct(bool $paginationEnabled = true, string $paginationPageParameterName = 'page', bool $clientItemsPerPage = false, string $itemsPerPageParameterName = 'itemsPerPage', bool $paginationClientEnabled = false, string $paginationClientEnabledParameterName = 'pagination') + { + $this->paginationEnabled = $paginationEnabled; + $this->paginationPageParameterName = $paginationPageParameterName; + $this->clientItemsPerPage = $clientItemsPerPage; + $this->itemsPerPageParameterName = $itemsPerPageParameterName; + $this->paginationClientEnabled = $paginationClientEnabled; + $this->paginationClientEnabledParameterName = $paginationClientEnabledParameterName; + } + + public function isPaginationEnabled(): bool + { + return $this->paginationEnabled; + } + + public function getPaginationPageParameterName(): string + { + return $this->paginationPageParameterName; + } + + public function getClientItemsPerPage(): bool + { + return $this->clientItemsPerPage; + } + + public function getItemsPerPageParameterName(): string + { + return $this->itemsPerPageParameterName; + } + + public function getPaginationClientEnabled(): bool + { + return $this->paginationClientEnabled; + } + + public function getPaginationClientEnabledParameterName(): string + { + return $this->paginationClientEnabledParameterName; + } +} diff --git a/src/Documentation/Action/DocumentationAction.php b/src/Documentation/Action/DocumentationAction.php index 57fd1698372..754fc013b4f 100644 --- a/src/Documentation/Action/DocumentationAction.php +++ b/src/Documentation/Action/DocumentationAction.php @@ -15,7 +15,9 @@ use ApiPlatform\Core\Api\FormatsProviderInterface; use ApiPlatform\Core\Documentation\Documentation; +use ApiPlatform\Core\Documentation\DocumentationInterface; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; +use ApiPlatform\Core\OpenApi\Factory\OpenApiFactoryInterface; use ApiPlatform\Core\Util\RequestAttributesExtractor; use Symfony\Component\HttpFoundation\Request; @@ -33,18 +35,24 @@ final class DocumentationAction private $formats; private $formatsProvider; private $swaggerVersions; + private $openApiFactory; /** * @param int[] $swaggerVersions * @param mixed|array|FormatsProviderInterface $formatsProvider */ - public function __construct(ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, string $title = '', string $description = '', string $version = '', $formatsProvider = null, array $swaggerVersions = [2, 3]) + public function __construct(ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, string $title = '', string $description = '', string $version = '', $formatsProvider = null, array $swaggerVersions = [2, 3], OpenApiFactoryInterface $openApiFactory = null) { $this->resourceNameCollectionFactory = $resourceNameCollectionFactory; $this->title = $title; $this->description = $description; $this->version = $version; $this->swaggerVersions = $swaggerVersions; + $this->openApiFactory = $openApiFactory; + + if (null === $openApiFactory) { + @trigger_error(sprintf('Not passing an instance of "%s" as 7th parameter of the constructor of "%s" is deprecated since API Platform 2.6', OpenApiFactoryInterface::class, __CLASS__), E_USER_DEPRECATED); + } if (null === $formatsProvider) { return; @@ -56,13 +64,14 @@ public function __construct(ResourceNameCollectionFactoryInterface $resourceName return; } + $this->formatsProvider = $formatsProvider; } - public function __invoke(Request $request = null): Documentation + public function __invoke(Request $request = null): DocumentationInterface { if (null !== $request) { - $context = ['base_url' => $request->getBaseUrl(), 'spec_version' => $request->query->getInt('spec_version', $this->swaggerVersions[0] ?? 2)]; + $context = ['base_url' => $request->getBaseUrl(), 'spec_version' => $request->query->getInt('spec_version', $this->swaggerVersions[0] ?? 3)]; if ($request->query->getBoolean('api_gateway')) { $context['api_gateway'] = true; } @@ -70,11 +79,16 @@ public function __invoke(Request $request = null): Documentation $attributes = RequestAttributesExtractor::extractAttributes($request); } + // BC check to be removed in 3.0 if (null !== $this->formatsProvider) { $this->formats = $this->formatsProvider->getFormatsFromAttributes($attributes ?? []); } + if (null !== $this->openApiFactory && isset($context) && 3 === $context['spec_version']) { + return $this->openApiFactory->__invoke($context ?? []); + } + return new Documentation($this->resourceNameCollectionFactory->create(), $this->title, $this->description, $this->version, $this->formats); } } diff --git a/src/Documentation/Documentation.php b/src/Documentation/Documentation.php index 53f5372df28..67228a77c5e 100644 --- a/src/Documentation/Documentation.php +++ b/src/Documentation/Documentation.php @@ -20,7 +20,7 @@ * * @author Amrouche Hamza */ -final class Documentation +final class Documentation implements DocumentationInterface { private $resourceNameCollection; private $title; diff --git a/src/Documentation/DocumentationInterface.php b/src/Documentation/DocumentationInterface.php new file mode 100644 index 00000000000..0bbea27c30c --- /dev/null +++ b/src/Documentation/DocumentationInterface.php @@ -0,0 +1,21 @@ + + * + * 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\Documentation; + +/** + * An API documentation. + */ +interface DocumentationInterface +{ +} diff --git a/src/OpenApi/Factory/OpenApiFactory.php b/src/OpenApi/Factory/OpenApiFactory.php new file mode 100644 index 00000000000..7ce8cc4d137 --- /dev/null +++ b/src/OpenApi/Factory/OpenApiFactory.php @@ -0,0 +1,448 @@ + + * + * 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\OpenApi\Factory; + +use ApiPlatform\Core\Api\FilterLocatorTrait; +use ApiPlatform\Core\Api\OperationType; +use ApiPlatform\Core\DataProvider\PaginationOptions; +use ApiPlatform\Core\JsonSchema\Schema; +use ApiPlatform\Core\JsonSchema\SchemaFactoryInterface; +use ApiPlatform\Core\JsonSchema\TypeFactoryInterface; +use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; +use ApiPlatform\Core\OpenApi\Model; +use ApiPlatform\Core\OpenApi\Model\ExternalDocumentation; +use ApiPlatform\Core\OpenApi\OpenApi; +use ApiPlatform\Core\OpenApi\Options; +use ApiPlatform\Core\Operation\Factory\SubresourceOperationFactoryInterface; +use ApiPlatform\Core\PathResolver\OperationPathResolverInterface; +use Psr\Container\ContainerInterface; +use Symfony\Component\PropertyInfo\Type; + +/** + * Generates an Open API v3 specification. + */ +final class OpenApiFactory implements OpenApiFactoryInterface +{ + use FilterLocatorTrait; + + public const BASE_URL = 'base_url'; + + private $resourceNameCollectionFactory; + private $resourceMetadataFactory; + private $propertyNameCollectionFactory; + private $propertyMetadataFactory; + private $operationPathResolver; + private $subresourceOperationFactory; + private $formats; + private $jsonSchemaFactory; + private $jsonSchemaTypeFactory; + private $openApiOptions; + private $paginationOptions; + + public function __construct(ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, ResourceMetadataFactoryInterface $resourceMetadataFactory, PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, SchemaFactoryInterface $jsonSchemaFactory, TypeFactoryInterface $jsonSchemaTypeFactory, OperationPathResolverInterface $operationPathResolver, ContainerInterface $filterLocator, SubresourceOperationFactoryInterface $subresourceOperationFactory, array $formats = [], Options $openApiOptions, PaginationOptions $paginationOptions) + { + $this->resourceNameCollectionFactory = $resourceNameCollectionFactory; + $this->jsonSchemaFactory = $jsonSchemaFactory; + $this->jsonSchemaTypeFactory = $jsonSchemaTypeFactory; + $this->formats = $formats; + $this->setFilterLocator($filterLocator, true); + $this->resourceMetadataFactory = $resourceMetadataFactory; + $this->propertyNameCollectionFactory = $propertyNameCollectionFactory; + $this->propertyMetadataFactory = $propertyMetadataFactory; + $this->operationPathResolver = $operationPathResolver; + $this->openApiOptions = $openApiOptions; + $this->paginationOptions = $paginationOptions; + $this->subresourceOperationFactory = $subresourceOperationFactory; + } + + /** + * {@inheritdoc} + */ + public function __invoke(array $context = []): OpenApi + { + $baseUrl = $context[self::BASE_URL] ?? '/'; + $info = new Model\Info($this->openApiOptions->getTitle(), $this->openApiOptions->getVersion(), trim($this->openApiOptions->getDescription())); + $servers = '/' === $baseUrl || '' === $baseUrl ? [] : [new Model\Server($baseUrl)]; + $paths = new Model\Paths(); + $links = []; + $schemas = []; + + foreach ($this->resourceNameCollectionFactory->create() as $resourceClass) { + $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass); + $resourceShortName = $resourceMetadata->getShortName(); + + // Items needs to be parsed first to be able to reference the lines from the collection operation + list($itemOperationLinks, $itemOperationSchemas) = $this->collectPaths($resourceMetadata, $resourceClass, OperationType::ITEM, $context, $paths, $links, $schemas); + $schemas += $itemOperationSchemas; + list($collectionOperationLinks, $collectionOperationSchemas) = $this->collectPaths($resourceMetadata, $resourceClass, OperationType::COLLECTION, $context, $paths, $links, $schemas); + + list($subresourceOperationLinks, $subresourceOperationSchemas) = $this->collectPaths($resourceMetadata, $resourceClass, OperationType::SUBRESOURCE, $context, $paths, $links, $schemas); + $schemas += $collectionOperationSchemas; + } + + $securitySchemes = $this->getSecuritySchemes(); + $securityRequirements = []; + + foreach (array_keys($securitySchemes) as $key) { + $securityRequirements[$key] = []; + } + + return new OpenApi($info, $servers, $paths, new Model\Components(new \ArrayObject($schemas), new \ArrayObject(), new \ArrayObject(), new \ArrayObject(), new \ArrayObject(), new \ArrayObject(), new \ArrayObject($securitySchemes)), $securityRequirements); + } + + /** + * @return array | array + */ + private function collectPaths(ResourceMetadata $resourceMetadata, string $resourceClass, string $operationType, array $context, Model\Paths $paths, array &$links, array $schemas = []): array + { + $resourceShortName = $resourceMetadata->getShortName(); + $operations = OperationType::COLLECTION === $operationType ? $resourceMetadata->getCollectionOperations() : (OperationType::ITEM === $operationType ? $resourceMetadata->getItemOperations() : $this->subresourceOperationFactory->create($resourceClass)); + if (!$operations) { + return [$links, $schemas]; + } + + foreach ($operations as $operationName => $operation) { + $path = $this->getPath($resourceShortName, $operationName, $operation, $operationType); + $method = $resourceMetadata->getTypedOperationAttribute($operationType, $operationName, 'method', 'GET'); + list($requestMimeTypes, $responseMimeTypes) = $this->getMimeTypes($resourceClass, $operationName, $operationType, $resourceMetadata); + $operationId = $operation['openapi_context']['operationId'] ?? lcfirst($operationName).ucfirst($resourceShortName).ucfirst($operationType); + $linkedOperationId = 'get'.ucfirst($resourceShortName).ucfirst(OperationType::ITEM); + $pathItem = $paths->getPath($path) ?: new Model\PathItem(); + + $operationOutputSchemas = []; + foreach ($responseMimeTypes as $operationFormat) { + $operationOutputSchema = $this->jsonSchemaFactory->buildSchema($resourceClass, $operationFormat, Schema::TYPE_OUTPUT, $operationType, $operationName, new Schema('openapi'), $context); + $schemas += $operationOutputSchema->getDefinitions()->getArrayCopy(); + $operationOutputSchemas[$operationFormat] = $operationOutputSchema; + } + + $parameters = []; + $responses = []; + + // Set up parameters + if (OperationType::ITEM === $operationType) { + $parameters[] = new Model\Parameter('id', 'path', 'Resource identifier', true, false, false, ['type' => 'string']); + $links[$operationId] = $this->getLink($resourceClass, $operationId, $path); + } elseif (OperationType::COLLECTION === $operationType && 'GET' === $method) { + $parameters = array_merge($parameters, $this->getPaginationParameters($resourceMetadata, $operationName), $this->getFiltersParameters($resourceMetadata, $operationName, $resourceClass)); + } elseif (OperationType::SUBRESOURCE === $operationType) { + // FIXME: In SubresourceOperationFactory identifiers may happen twice + $added = []; + foreach ($operation['identifiers'] as $identifier) { + if (\in_array($identifier[0], $added, true)) { + continue; + } + $added[] = $identifier[0]; + $parameterShortname = $this->resourceMetadataFactory->create($identifier[1])->getShortName(); + $parameters[] = new Model\Parameter($identifier[0], 'path', $parameterShortname.' identifier', true, false, false, ['type' => 'string']); + } + + if ($operation['collection']) { + $parameters = array_merge($parameters, $this->getPaginationParameters($resourceMetadata, $operationName), $this->getFiltersParameters($resourceMetadata, $operationName, $resourceClass)); + } + } + + // Create responses + switch ($method) { + case 'GET': + $successStatus = (string) $resourceMetadata->getTypedOperationAttribute($operationType, $operationName, 'status', '200'); + $responseContent = $this->buildContent($responseMimeTypes, $operationOutputSchemas); + $responses[$successStatus] = new Model\Response(sprintf('%s %s', $resourceShortName, OperationType::COLLECTION === $operationType ? 'collection' : 'resource'), $responseContent); + break; + case 'POST': + $responseLinks = new \ArrayObject(isset($links[$linkedOperationId]) ? [ucfirst($linkedOperationId) => $links[$linkedOperationId]] : []); + $responseContent = $this->buildContent($responseMimeTypes, $operationOutputSchemas); + $successStatus = (string) $resourceMetadata->getTypedOperationAttribute($operationType, $operationName, 'status', '201'); + $responses[$successStatus] = new Model\Response(sprintf('%s resource created', $resourceShortName), $responseContent, null, $responseLinks); + $responses['400'] = new Model\Response('Invalid input'); + break; + case 'PATCH': + case 'PUT': + $responseLinks = new \ArrayObject(isset($links[$linkedOperationId]) ? [ucfirst($linkedOperationId) => $links[$linkedOperationId]] : []); + $successStatus = (string) $resourceMetadata->getTypedOperationAttribute($operationType, $operationName, 'status', '200'); + $responseContent = $this->buildContent($responseMimeTypes, $operationOutputSchemas); + $responses[$successStatus] = new Model\Response(sprintf('%s resource updated', $resourceShortName), $responseContent, null, $responseLinks); + $responses['400'] = new Model\Response('Invalid input'); + break; + case 'DELETE': + $successStatus = (string) $resourceMetadata->getTypedOperationAttribute($operationType, $operationName, 'status', '204'); + $responses[$successStatus] = new Model\Response(sprintf('%s resource deleted', $resourceShortName)); + break; + } + + if (OperationType::ITEM === $operationType) { + $responses['404'] = new Model\Response('Resource not found'); + } + + if (!$responses) { + $responses['default'] = new Model\Response('Unexpected error'); + } + + $requestBody = null; + if ('PUT' === $method || 'POST' === $method || 'PATCH' === $method) { + $operationInputSchemas = []; + foreach ($requestMimeTypes as $operationFormat) { + $operationInputSchema = $this->jsonSchemaFactory->buildSchema($resourceClass, $operationFormat, Schema::TYPE_INPUT, $operationType, $operationName, new Schema('openapi'), $context); + $schemas += $operationInputSchema->getDefinitions()->getArrayCopy(); + $operationInputSchemas[$operationFormat] = $operationInputSchema; + } + + $requestBody = new Model\RequestBody(sprintf('The %s %s resource', 'POST' === $method ? 'new' : 'updated', $resourceShortName), $this->buildContent($requestMimeTypes, $operationInputSchemas), true); + } + + $pathItem = $pathItem->{'with'.ucfirst($method)}(new Model\Operation( + $operationId, + $operation['openapi_context']['tags'] ?? (OperationType::SUBRESOURCE === $operationType ? $operation['shortNames'] : [$resourceShortName]), + $responses, + $operation['openapi_context']['summary'] ?? '', + $operation['openapi_context']['description'] ?? $this->getPathDescription($resourceShortName, $method, $operationType), + isset($operation['openapi_context']['externalDocs']) ? new ExternalDocumentation($operation['openapi_context']['externalDocs']['description'] ?? null, $operation['openapi_context']['externalDocs']['url']) : null, + $parameters, + $requestBody, + isset($operation['openapi_context']['callbacks']) ? new \ArrayObject($operation['openapi_context']['callbacks']) : null, + $operation['openapi_context']['deprecated'] ?? (bool) $resourceMetadata->getTypedOperationAttribute($operationType, $operationName, 'deprecation_reason', false, true), + $operation['openapi_context']['security'] ?? [], + $operation['openapi_context']['servers'] ?? [] + )); + + $paths->addPath($path, $pathItem); + } + + return [$links, $schemas]; + } + + private function buildContent(array $responseMimeTypes, array $operationSchemas): \ArrayObject + { + $content = new \ArrayObject(); + + foreach ($responseMimeTypes as $mimeType => $format) { + $content[$mimeType] = new Model\MediaType(new \ArrayObject($operationSchemas[$format]->getArrayCopy(false))); + } + + return $content; + } + + private function getMimeTypes(string $resourceClass, string $operationName, string $operationType, ResourceMetadata $resourceMetadata = null): array + { + $requestFormats = $resourceMetadata->getTypedOperationAttribute($operationType, $operationName, 'input_formats', $this->formats, true); + $responseFormats = $resourceMetadata->getTypedOperationAttribute($operationType, $operationName, 'output_formats', $this->formats, true); + + $requestMimeTypes = $this->flattenMimeTypes($requestFormats); + $responseMimeTypes = $this->flattenMimeTypes($responseFormats); + + return [$requestMimeTypes, $responseMimeTypes]; + } + + private function flattenMimeTypes(array $responseFormats): array + { + $responseMimeTypes = []; + foreach ($responseFormats as $responseFormat => $mimeTypes) { + foreach ($mimeTypes as $mimeType) { + $responseMimeTypes[$mimeType] = $responseFormat; + } + } + + return $responseMimeTypes; + } + + /** + * Gets the path for an operation. + * + * If the path ends with the optional _format parameter, it is removed + * as optional path parameters are not yet supported. + * + * @see https://github.com/OAI/OpenAPI-Specification/issues/93 + */ + private function getPath(string $resourceShortName, string $operationName, array $operation, string $operationType): string + { + if ($operation['path'] ?? null) { + return 0 === strpos($operation['path'], '/') ? $operation['path'] : '/'.$operation['path']; + } + $path = $this->operationPathResolver->resolveOperationPath($resourceShortName, $operation, $operationType, $operationName); + if ('.{_format}' === substr($path, -10)) { + $path = substr($path, 0, -10); + } + + return $path; + } + + private function getPathDescription(string $resourceShortName, string $method, string $operationType): string + { + switch ($method) { + case 'GET': + $pathSummary = OperationType::COLLECTION === $operationType ? 'Retrieves the collection of %s resources.' : 'Retrieves a %s resource.'; + break; + case 'POST': + $pathSummary = 'Creates a %s resource.'; + break; + case 'PATCH': + $pathSummary = 'Updates the %s resource.'; + break; + case 'PUT': + $pathSummary = 'Replaces the %s resource.'; + break; + case 'DELETE': + $pathSummary = 'Removes the %s resource.'; + break; + default: + return $resourceShortName; + } + + return sprintf($pathSummary, $resourceShortName); + } + + /** + * @see https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#linkObject. + */ + private function getLink(string $resourceClass, string $operationId, string $path): Model\Link + { + $parameters = []; + + foreach ($this->propertyNameCollectionFactory->create($resourceClass) as $propertyName) { + $propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $propertyName); + if (!$propertyMetadata->isIdentifier()) { + continue; + } + + $parameters[$propertyName] = sprintf('$response.body#/%s', $propertyName); + } + + return new Model\Link( + $operationId, + new \ArrayObject($parameters), + [], + 1 === \count($parameters) ? sprintf('The `%1$s` value returned in the response can be used as the `%1$s` parameter in `GET %2$s`.', key($parameters), $path) : sprintf('The values returned in the response can be used in `GET %s`.', $path) + ); + } + + /** + * Gets parameters corresponding to enabled filters. + */ + private function getFiltersParameters(ResourceMetadata $resourceMetadata, string $operationName, string $resourceClass): array + { + $parameters = []; + $resourceFilters = $resourceMetadata->getCollectionOperationAttribute($operationName, 'filters', [], true); + foreach ($resourceFilters as $filterId) { + if (!$filter = $this->getFilter($filterId)) { + continue; + } + + foreach ($filter->getDescription($resourceClass) as $name => $data) { + $schema = $data['schema'] ?? \in_array($data['type'], Type::$builtinTypes, true) ? $this->jsonSchemaTypeFactory->getType(new Type($data['type'], false, null, $data['is_collection'] ?? false)) : ['type' => 'string']; + + $parameters[] = new Model\Parameter( + $name, + 'query', + $data['description'] ?? '', + $data['required'] ?? false, + $data['openapi']['deprecated'] ?? false, + $data['openapi']['allowEmptyValue'] ?? true, + $schema, + 'array' === $schema['type'] && \in_array($data['type'], + [Type::BUILTIN_TYPE_ARRAY, Type::BUILTIN_TYPE_OBJECT], true) ? 'deepObject' : 'form', + 'array' === $schema['type'], + $data['openapi']['allowReserved'] ?? false, + $data['openapi']['example'] ?? null, + isset($data['openapi']['examples'] + ) ? new \ArrayObject($data['openapi']['examples']) : null); + } + } + + return $parameters; + } + + private function getPaginationParameters(ResourceMetadata $resourceMetadata, string $operationName): array + { + if (!$this->paginationOptions->isPaginationEnabled()) { + return []; + } + + $parameters = []; + + if ($resourceMetadata->getCollectionOperationAttribute($operationName, 'pagination_enabled', true, true)) { + $parameters[] = new Model\Parameter($this->paginationOptions->getPaginationPageParameterName(), 'query', 'The collection page number', false, false, true, ['type' => 'integer', 'default' => 1]); + + if ($resourceMetadata->getCollectionOperationAttribute($operationName, 'pagination_client_items_per_page', $this->paginationOptions->getClientItemsPerPage(), true)) { + $schema = [ + 'type' => 'integer', + 'default' => $resourceMetadata->getCollectionOperationAttribute($operationName, 'pagination_items_per_page', 30, true), + 'minimum' => 0, + ]; + + if (null !== $maxItemsPerPage = $resourceMetadata->getCollectionOperationAttribute($operationName, 'pagination_maximum_items_per_page', null, true)) { + $schema['maximum'] = $maxItemsPerPage; + } + + $parameters[] = new Model\Parameter($this->paginationOptions->getItemsPerPageParameterName(), 'query', 'The number of items per page', false, false, true, $schema); + } + } + + if ($resourceMetadata->getCollectionOperationAttribute($operationName, 'pagination_client_enabled', $this->paginationOptions->getPaginationClientEnabled(), true)) { + $parameters[] = new Model\Parameter($this->paginationOptions->getPaginationClientEnabledParameterName(), 'query', 'Enable or disable pagination', false, false, true, ['type' => 'boolean']); + } + + return $parameters; + } + + private function getOauthSecurityScheme(): Model\SecurityScheme + { + $oauthFlow = new Model\OAuthFlow($this->openApiOptions->getOAuthAuthorizationUrl(), $this->openApiOptions->getOAuthTokenUrl(), $this->openApiOptions->getOAuthRefreshUrl(), new \ArrayObject($this->openApiOptions->getOAuthScopes())); + $description = sprintf( + 'OAuth 2.0 %s Grant', + strtolower(preg_replace('/[A-Z]/', ' \\0', lcfirst($this->openApiOptions->getOAuthFlow()))) + ); + $implicit = $password = $clientCredentials = $authorizationCode = null; + + switch ($this->openApiOptions->getOAuthFlow()) { + case 'implicit': + $implicit = $oauthFlow; + break; + case 'password': + $password = $oauthFlow; + break; + case 'application': + case 'clientCredentials': + $clientCredentials = $oauthFlow; + break; + case 'accessCode': + case 'authorizationCode': + $authorizationCode = $oauthFlow; + break; + default: + throw new \LogicException('OAuth flow must be one of: implicit, password, clientCredentials, authorizationCode'); + } + + return new Model\SecurityScheme($this->openApiOptions->getOAuthType(), $description, null, null, 'oauth2', null, new Model\OAuthFlows($implicit, $password, $clientCredentials, $authorizationCode), null); + } + + private function getSecuritySchemes(): array + { + $securitySchemes = []; + + if ($this->openApiOptions->getOAuthEnabled()) { + $securitySchemes['oauth'] = $this->getOauthSecurityScheme(); + } + + foreach ($this->openApiOptions->getApiKeys() as $key => $apiKey) { + $description = sprintf('Value for the %s %s parameter.', $apiKey['name'], $apiKey['type']); + $securitySchemes[$key] = new Model\SecurityScheme('apiKey', $description, $apiKey['name'], $apiKey['type'], 'bearer'); + } + + return $securitySchemes; + } +} diff --git a/src/OpenApi/Factory/OpenApiFactoryInterface.php b/src/OpenApi/Factory/OpenApiFactoryInterface.php new file mode 100644 index 00000000000..d4b04bb190f --- /dev/null +++ b/src/OpenApi/Factory/OpenApiFactoryInterface.php @@ -0,0 +1,24 @@ + + * + * 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\OpenApi\Factory; + +use ApiPlatform\Core\OpenApi\OpenApi; + +interface OpenApiFactoryInterface +{ + /** + * Creates an OpenApi class. + */ + public function __invoke(array $context = []): OpenApi; +} diff --git a/src/OpenApi/Model/Components.php b/src/OpenApi/Model/Components.php new file mode 100644 index 00000000000..1035ab1d7f0 --- /dev/null +++ b/src/OpenApi/Model/Components.php @@ -0,0 +1,159 @@ + + * + * 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\OpenApi\Model; + +final class Components +{ + use ExtensionTrait; + + private $schemas; + private $responses; + private $parameters; + private $examples; + private $requestBodies; + private $headers; + private $securitySchemes; + private $links; + private $callbacks; + + public function __construct(\ArrayObject $schemas = null, \ArrayObject $responses = null, \ArrayObject $parameters = null, \ArrayObject $examples = null, \ArrayObject $requestBodies = null, \ArrayObject $headers = null, \ArrayObject $securitySchemes = null, \ArrayObject $links = null, \ArrayObject $callbacks = null) + { + $this->schemas = $schemas; + $this->responses = $responses; + $this->parameters = $parameters; + $this->examples = $examples; + $this->requestBodies = $requestBodies; + $this->headers = $headers; + $this->securitySchemes = $securitySchemes; + $this->links = $links; + $this->callbacks = $callbacks; + } + + public function getSchemas(): ?\ArrayObject + { + return $this->schemas; + } + + public function getResponses(): ?\ArrayObject + { + return $this->responses; + } + + public function getParameters(): ?\ArrayObject + { + return $this->parameters; + } + + public function getExamples(): ?\ArrayObject + { + return $this->examples; + } + + public function getRequestBodies(): ?\ArrayObject + { + return $this->requestBodies; + } + + public function getHeaders(): ?\ArrayObject + { + return $this->headers; + } + + public function getSecuritySchemes(): ?\ArrayObject + { + return $this->securitySchemes; + } + + public function getLinks(): ?\ArrayObject + { + return $this->links; + } + + public function getCallbacks(): ?\ArrayObject + { + return $this->callbacks; + } + + public function withSchemas(\ArrayObject $schemas): self + { + $clone = clone $this; + $clone->schemas = $schemas; + + return $clone; + } + + public function withResponses(\ArrayObject $responses): self + { + $clone = clone $this; + $clone->responses = $responses; + + return $clone; + } + + public function withParameters(\ArrayObject $parameters): self + { + $clone = clone $this; + $clone->parameters = $parameters; + + return $clone; + } + + public function withExamples(\ArrayObject $examples): self + { + $clone = clone $this; + $clone->examples = $examples; + + return $clone; + } + + public function withRequestBodies(\ArrayObject $requestBodies): self + { + $clone = clone $this; + $clone->requestBodies = $requestBodies; + + return $clone; + } + + public function withHeaders(\ArrayObject $headers): self + { + $clone = clone $this; + $clone->headers = $headers; + + return $clone; + } + + public function withSecuritySchemes(\ArrayObject $securitySchemes): self + { + $clone = clone $this; + $clone->securitySchemes = $securitySchemes; + + return $clone; + } + + public function withLinks(\ArrayObject $links): self + { + $clone = clone $this; + $clone->links = $links; + + return $clone; + } + + public function withCallbacks(\ArrayObject $callbacks): self + { + $clone = clone $this; + $clone->callbacks = $callbacks; + + return $clone; + } +} diff --git a/src/OpenApi/Model/Contact.php b/src/OpenApi/Model/Contact.php new file mode 100644 index 00000000000..713de0ff65e --- /dev/null +++ b/src/OpenApi/Model/Contact.php @@ -0,0 +1,69 @@ + + * + * 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\OpenApi\Model; + +final class Contact +{ + use ExtensionTrait; + + private $name; + private $url; + private $email; + + public function __construct(string $name = '', string $url = '', string $email = '') + { + $this->name = $name; + $this->url = $url; + $this->email = $email; + } + + public function getName(): string + { + return $this->name; + } + + public function getUrl(): string + { + return $this->url; + } + + public function getEmail(): string + { + return $this->email; + } + + public function withName(string $name): self + { + $clone = clone $this; + $clone->name = $name; + + return $clone; + } + + public function withUrl(string $url): self + { + $clone = clone $this; + $clone->url = $url; + + return $clone; + } + + public function withEmail(string $email): self + { + $clone = clone $this; + $clone->email = $email; + + return $clone; + } +} diff --git a/src/OpenApi/Model/Encoding.php b/src/OpenApi/Model/Encoding.php new file mode 100644 index 00000000000..12b8d91a1d2 --- /dev/null +++ b/src/OpenApi/Model/Encoding.php @@ -0,0 +1,99 @@ + + * + * 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\OpenApi\Model; + +final class Encoding +{ + use ExtensionTrait; + + private $contentType; + private $headers; + private $style; + private $explode; + private $allowReserved; + + public function __construct(string $contentType = '', \ArrayObject $headers = null, string $style = '', bool $explode = false, bool $allowReserved = false) + { + $this->contentType = $contentType; + $this->headers = $headers; + $this->style = $style; + $this->explode = $explode; + $this->allowReserved = $allowReserved; + } + + public function getContentType(): string + { + return $this->contentType; + } + + public function getHeaders(): ?\ArrayObject + { + return $this->headers; + } + + public function getStyle(): string + { + return $this->style; + } + + public function canExplode(): bool + { + return $this->explode; + } + + public function canAllowReserved(): bool + { + return $this->allowReserved; + } + + public function withContentType(string $contentType): self + { + $clone = clone $this; + $clone->contentType = $contentType; + + return $clone; + } + + public function withHeaders(?\ArrayObject $headers): self + { + $clone = clone $this; + $clone->headers = $headers; + + return $clone; + } + + public function withStyle(string $style): self + { + $clone = clone $this; + $clone->style = $style; + + return $clone; + } + + public function withExplode(bool $explode): self + { + $clone = clone $this; + $clone->explode = $explode; + + return $clone; + } + + public function withAllowReserved(bool $allowReserved): self + { + $clone = clone $this; + $clone->allowReserved = $allowReserved; + + return $clone; + } +} diff --git a/src/OpenApi/Model/ExtensionTrait.php b/src/OpenApi/Model/ExtensionTrait.php new file mode 100644 index 00000000000..62f97f2151d --- /dev/null +++ b/src/OpenApi/Model/ExtensionTrait.php @@ -0,0 +1,36 @@ + + * + * 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\OpenApi\Model; + +trait ExtensionTrait +{ + private $extensionProperties = []; + + public function withExtensionProperty(string $key, string $value) + { + if (0 !== strpos($key, 'x-')) { + $key = 'x-'.$key; + } + + $clone = clone $this; + $clone->extensionProperties[$key] = $value; + + return $clone; + } + + public function getExtensionProperties(): array + { + return $this->extensionProperties; + } +} diff --git a/src/OpenApi/Model/ExternalDocumentation.php b/src/OpenApi/Model/ExternalDocumentation.php new file mode 100644 index 00000000000..f606a26b4d7 --- /dev/null +++ b/src/OpenApi/Model/ExternalDocumentation.php @@ -0,0 +1,54 @@ + + * + * 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\OpenApi\Model; + +final class ExternalDocumentation +{ + use ExtensionTrait; + + private $description; + private $url; + + public function __construct(string $description = '', string $url = '') + { + $this->description = $description; + $this->url = $url; + } + + public function getDescription(): string + { + return $this->description; + } + + public function getUrl(): string + { + return $this->url; + } + + public function withDescription(string $description): self + { + $clone = clone $this; + $clone->description = $description; + + return $clone; + } + + public function withUrl(string $url): self + { + $clone = clone $this; + $clone->url = $url; + + return $clone; + } +} diff --git a/src/OpenApi/Model/Info.php b/src/OpenApi/Model/Info.php new file mode 100644 index 00000000000..9b16e9e8909 --- /dev/null +++ b/src/OpenApi/Model/Info.php @@ -0,0 +1,114 @@ + + * + * 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\OpenApi\Model; + +final class Info +{ + use ExtensionTrait; + + private $title; + private $description; + private $termsOfService; + private $contact; + private $license; + private $version; + + public function __construct(string $title, string $version, string $description = '', string $termsOfService = null, Contact $contact = null, License $license = null) + { + $this->title = $title; + $this->version = $version; + $this->description = $description; + $this->termsOfService = $termsOfService; + $this->contact = $contact; + $this->license = $license; + } + + public function getTitle(): string + { + return $this->title; + } + + public function getDescription(): string + { + return $this->description; + } + + public function getTermsOfService(): ?string + { + return $this->termsOfService; + } + + public function getContact(): ?Contact + { + return $this->contact; + } + + public function getLicense(): ?License + { + return $this->license; + } + + public function getVersion(): string + { + return $this->version; + } + + public function withTitle(string $title): self + { + $info = clone $this; + $info->title = $title; + + return $info; + } + + public function withDescription(string $description): self + { + $clone = clone $this; + $clone->description = $description; + + return $clone; + } + + public function withTermsOfService(string $termsOfService): self + { + $clone = clone $this; + $clone->termsOfService = $termsOfService; + + return $clone; + } + + public function withContact(Contact $contact): self + { + $clone = clone $this; + $clone->contact = $contact; + + return $clone; + } + + public function withLicense(License $license): self + { + $clone = clone $this; + $clone->license = $license; + + return $clone; + } + + public function withVersion(string $version): self + { + $clone = clone $this; + $clone->version = $version; + + return $clone; + } +} diff --git a/src/OpenApi/Model/License.php b/src/OpenApi/Model/License.php new file mode 100644 index 00000000000..ebc183c9aa0 --- /dev/null +++ b/src/OpenApi/Model/License.php @@ -0,0 +1,54 @@ + + * + * 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\OpenApi\Model; + +final class License +{ + use ExtensionTrait; + + private $name; + private $url; + + public function __construct(string $name, string $url) + { + $this->name = $name; + $this->url = $url; + } + + public function getName(): string + { + return $this->name; + } + + public function getUrl(): string + { + return $this->url; + } + + public function withName(string $name): self + { + $clone = clone $this; + $clone->name = $name; + + return $clone; + } + + public function withUrl(string $url): self + { + $clone = clone $this; + $clone->url = $url; + + return $clone; + } +} diff --git a/src/OpenApi/Model/Link.php b/src/OpenApi/Model/Link.php new file mode 100644 index 00000000000..a0feb9511d6 --- /dev/null +++ b/src/OpenApi/Model/Link.php @@ -0,0 +1,99 @@ + + * + * 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\OpenApi\Model; + +final class Link +{ + use ExtensionTrait; + + private $operationId; + private $parameters; + private $requestBody; + private $description; + private $server; + + public function __construct(string $operationId, \ArrayObject $parameters = null, $requestBody = null, string $description = '', Server $server = null) + { + $this->operationId = $operationId; + $this->parameters = $parameters; + $this->requestBody = $requestBody; + $this->description = $description; + $this->server = $server; + } + + public function getOperationId(): string + { + return $this->operationId; + } + + public function getParameters(): \ArrayObject + { + return $this->parameters; + } + + public function getRequestBody() + { + return $this->requestBody; + } + + public function getDescription(): string + { + return $this->description; + } + + public function getServer(): ?Server + { + return $this->server; + } + + public function withOperationId(string $operationId): self + { + $clone = clone $this; + $clone->operationId = $operationId; + + return $clone; + } + + public function withParameters(\ArrayObject $parameters): self + { + $clone = clone $this; + $clone->parameters = $parameters; + + return $clone; + } + + public function withRequestBody($requestBody): self + { + $clone = clone $this; + $clone->requestBody = $requestBody; + + return $clone; + } + + public function withDescription(string $description): self + { + $clone = clone $this; + $clone->description = $description; + + return $clone; + } + + public function withServer(Server $server): self + { + $clone = clone $this; + $clone->server = $server; + + return $clone; + } +} diff --git a/src/OpenApi/Model/MediaType.php b/src/OpenApi/Model/MediaType.php new file mode 100644 index 00000000000..8f232bb4b4b --- /dev/null +++ b/src/OpenApi/Model/MediaType.php @@ -0,0 +1,84 @@ + + * + * 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\OpenApi\Model; + +final class MediaType +{ + use ExtensionTrait; + + private $schema; + private $example; + private $examples; + private $encoding; + + public function __construct(\ArrayObject $schema = null, $example = null, \ArrayObject $examples = null, Encoding $encoding = null) + { + $this->schema = $schema; + $this->example = $example; + $this->examples = $examples; + $this->encoding = $encoding; + } + + public function getSchema(): ?\ArrayObject + { + return $this->schema; + } + + public function getExample() + { + return $this->example; + } + + public function getExamples(): ?\ArrayObject + { + return $this->examples; + } + + public function getEncoding(): ?Encoding + { + return $this->encoding; + } + + public function withSchema(\ArrayObject $schema): self + { + $clone = clone $this; + $clone->schema = $schema; + + return $clone; + } + + public function withExample($example): self + { + $clone = clone $this; + $clone->example = $example; + + return $clone; + } + + public function withExamples(\ArrayObject $examples): self + { + $clone = clone $this; + $clone->examples = $examples; + + return $clone; + } + + public function withEncoding(Encoding $encoding): self + { + $clone = clone $this; + $clone->encoding = $encoding; + + return $clone; + } +} diff --git a/src/OpenApi/Model/OAuthFlow.php b/src/OpenApi/Model/OAuthFlow.php new file mode 100644 index 00000000000..1b3dfd4849b --- /dev/null +++ b/src/OpenApi/Model/OAuthFlow.php @@ -0,0 +1,84 @@ + + * + * 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\OpenApi\Model; + +final class OAuthFlow +{ + use ExtensionTrait; + + private $authorizationUrl; + private $tokenUrl; + private $refreshUrl; + private $scopes; + + public function __construct(string $authorizationUrl = null, string $tokenUrl = null, string $refreshUrl = null, \ArrayObject $scopes = null) + { + $this->authorizationUrl = $authorizationUrl; + $this->tokenUrl = $tokenUrl; + $this->refreshUrl = $refreshUrl; + $this->scopes = $scopes; + } + + public function getAuthorizationUrl(): ?string + { + return $this->authorizationUrl; + } + + public function getTokenUrl(): ?string + { + return $this->tokenUrl; + } + + public function getRefreshUrl(): ?string + { + return $this->refreshUrl; + } + + public function getScopes(): \ArrayObject + { + return $this->scopes; + } + + public function withAuthorizationUrl(string $authorizationUrl): self + { + $clone = clone $this; + $clone->authorizationUrl = $authorizationUrl; + + return $clone; + } + + public function withTokenUrl(string $tokenUrl): self + { + $clone = clone $this; + $clone->tokenUrl = $tokenUrl; + + return $clone; + } + + public function withRefreshUrl(string $refreshUrl): self + { + $clone = clone $this; + $clone->refreshUrl = $refreshUrl; + + return $clone; + } + + public function withScopes(\ArrayObject $scopes): self + { + $clone = clone $this; + $clone->scopes = $scopes; + + return $clone; + } +} diff --git a/src/OpenApi/Model/OAuthFlows.php b/src/OpenApi/Model/OAuthFlows.php new file mode 100644 index 00000000000..afe06ad4824 --- /dev/null +++ b/src/OpenApi/Model/OAuthFlows.php @@ -0,0 +1,84 @@ + + * + * 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\OpenApi\Model; + +final class OAuthFlows +{ + use ExtensionTrait; + + private $implicit; + private $password; + private $clientCredentials; + private $authorizationCode; + + public function __construct(OAuthFlow $implicit = null, OAuthFlow $password = null, OAuthFlow $clientCredentials = null, OAuthFlow $authorizationCode = null) + { + $this->implicit = $implicit; + $this->password = $password; + $this->clientCredentials = $clientCredentials; + $this->authorizationCode = $authorizationCode; + } + + public function getImplicit(): ?OAuthFlow + { + return $this->implicit; + } + + public function getPassword(): ?OAuthFlow + { + return $this->password; + } + + public function getClientCredentials(): ?OAuthFlow + { + return $this->clientCredentials; + } + + public function getAuthorizationCode(): ?OAuthFlow + { + return $this->authorizationCode; + } + + public function withImplicit(OAuthFlow $implicit): self + { + $clone = clone $this; + $clone->implicit = $implicit; + + return $clone; + } + + public function withPassword(OAuthFlow $password): self + { + $clone = clone $this; + $clone->password = $password; + + return $clone; + } + + public function withClientCredentials(OAuthFlow $clientCredentials): self + { + $clone = clone $this; + $clone->clientCredentials = $clientCredentials; + + return $clone; + } + + public function withAuthorizationCode(OAuthFlow $authorizationCode): self + { + $clone = clone $this; + $clone->authorizationCode = $authorizationCode; + + return $clone; + } +} diff --git a/src/OpenApi/Model/Operation.php b/src/OpenApi/Model/Operation.php new file mode 100644 index 00000000000..c5b7fd76faf --- /dev/null +++ b/src/OpenApi/Model/Operation.php @@ -0,0 +1,211 @@ + + * + * 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\OpenApi\Model; + +final class Operation +{ + use ExtensionTrait; + + private $tags; + private $summary; + private $description; + private $operationId; + private $parameters; + private $requestBody; + private $responses; + private $callbacks; + private $deprecated; + private $security; + private $servers; + private $externalDocs; + + public function __construct(string $operationId = null, array $tags = [], array $responses = [], string $summary = '', string $description = '', ExternalDocumentation $externalDocs = null, array $parameters = [], RequestBody $requestBody = null, \ArrayObject $callbacks = null, bool $deprecated = false, array $security = [], array $servers = []) + { + $this->tags = $tags; + $this->summary = $summary; + $this->description = $description; + $this->operationId = $operationId; + $this->parameters = $parameters; + $this->requestBody = $requestBody; + $this->responses = $responses; + $this->callbacks = $callbacks; + $this->deprecated = $deprecated; + $this->security = $security; + $this->servers = $servers; + $this->externalDocs = $externalDocs; + } + + public function addResponse(Response $response, $status = 'default'): self + { + $this->responses[$status] = $response; + + return $this; + } + + public function getOperationId(): string + { + return $this->operationId; + } + + public function getTags(): array + { + return $this->tags; + } + + public function getResponses(): array + { + return $this->responses; + } + + public function getSummary(): string + { + return $this->summary; + } + + public function getDescription(): string + { + return $this->description; + } + + public function getExternalDocs(): ?ExternalDocumentation + { + return $this->externalDocs; + } + + public function getParameters(): array + { + return $this->parameters; + } + + public function getRequestBody(): ?RequestBody + { + return $this->requestBody; + } + + public function getCallbacks(): ?\ArrayObject + { + return $this->callbacks; + } + + public function getDeprecated(): bool + { + return $this->deprecated; + } + + public function getSecurity(): array + { + return $this->security; + } + + public function getServers(): array + { + return $this->servers; + } + + public function withOperationId(string $operationId): self + { + $clone = clone $this; + $clone->operationId = $operationId; + + return $clone; + } + + public function withTags(array $tags): self + { + $clone = clone $this; + $clone->tags = $tags; + + return $clone; + } + + public function withResponses(array $responses): self + { + $clone = clone $this; + $clone->responses = $responses; + + return $clone; + } + + public function withSummary(string $summary): self + { + $clone = clone $this; + $clone->summary = $summary; + + return $clone; + } + + public function withDescription(string $description): self + { + $clone = clone $this; + $clone->description = $description; + + return $clone; + } + + public function withExternalDocs(ExternalDocumentation $externalDocs): self + { + $clone = clone $this; + $clone->externalDocs = $externalDocs; + + return $clone; + } + + public function withParameters(array $parameters): self + { + $clone = clone $this; + $clone->parameters = $parameters; + + return $clone; + } + + public function withRequestBody(RequestBody $requestBody): self + { + $clone = clone $this; + $clone->requestBody = $requestBody; + + return $clone; + } + + public function withCallbacks(\ArrayObject $callbacks): self + { + $clone = clone $this; + $clone->callbacks = $callbacks; + + return $clone; + } + + public function withDeprecated(bool $deprecated): self + { + $clone = clone $this; + $clone->deprecated = $deprecated; + + return $clone; + } + + public function withSecurity(array $security): self + { + $clone = clone $this; + $clone->security = $security; + + return $clone; + } + + public function withServers(array $servers): self + { + $clone = clone $this; + $clone->servers = $servers; + + return $clone; + } +} diff --git a/src/OpenApi/Model/Parameter.php b/src/OpenApi/Model/Parameter.php new file mode 100644 index 00000000000..f68f0cbd231 --- /dev/null +++ b/src/OpenApi/Model/Parameter.php @@ -0,0 +1,227 @@ + + * + * 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\OpenApi\Model; + +final class Parameter +{ + use ExtensionTrait; + + private $name; + private $in; + private $description; + private $required; + private $deprecated; + private $allowEmptyValue; + private $schema; + private $explode; + private $allowReserved; + private $style; + private $example; + private $examples; + private $content; + + public function __construct(string $name, string $in, string $description = '', bool $required = false, bool $deprecated = false, bool $allowEmptyValue = false, array $schema = [], string $style = null, bool $explode = false, bool $allowReserved = false, $example = null, \ArrayObject $examples = null, \ArrayObject $content = null) + { + $this->name = $name; + $this->in = $in; + $this->description = $description; + $this->required = $required; + $this->deprecated = $deprecated; + $this->allowEmptyValue = $allowEmptyValue; + $this->schema = $schema; + $this->explode = $explode; + $this->allowReserved = $allowReserved; + $this->example = $example; + $this->examples = $examples; + $this->content = $content; + $this->style = $style; + + if (null === $style) { + if ('query' === $in || 'cookie' === $in) { + $this->style = 'form'; + } elseif ('path' === $in || 'header' === $in) { + $this->style = 'simple'; + } + } + } + + public function getName(): ?string + { + return $this->name; + } + + public function getIn(): ?string + { + return $this->in; + } + + public function getDescription(): string + { + return $this->description; + } + + public function getRequired(): bool + { + return $this->required; + } + + public function getDeprecated(): bool + { + return $this->deprecated; + } + + public function canAllowEmptyValue(): bool + { + return $this->allowEmptyValue; + } + + public function getSchema(): array + { + return $this->schema; + } + + public function getStyle(): string + { + return $this->style; + } + + public function canExplode(): bool + { + return $this->explode; + } + + public function canAllowReserved(): bool + { + return $this->allowReserved; + } + + public function getExample() + { + return $this->example; + } + + public function getExamples(): ?\ArrayObject + { + return $this->examples; + } + + public function getContent(): ?\ArrayObject + { + return $this->content; + } + + public function withName(string $name): self + { + $clone = clone $this; + $clone->name = $name; + + return $clone; + } + + public function withIn(string $in): self + { + $clone = clone $this; + $clone->in = $in; + + return $clone; + } + + public function withDescription(string $description): self + { + $clone = clone $this; + $clone->description = $description; + + return $clone; + } + + public function withRequired(bool $required): self + { + $clone = clone $this; + $clone->required = $required; + + return $clone; + } + + public function withDeprecated(bool $deprecated): self + { + $clone = clone $this; + $clone->deprecated = $deprecated; + + return $clone; + } + + public function withAllowEmptyValue(bool $allowEmptyValue): self + { + $clone = clone $this; + $clone->allowEmptyValue = $allowEmptyValue; + + return $clone; + } + + public function withSchema(array $schema): self + { + $clone = clone $this; + $clone->schema = $schema; + + return $clone; + } + + public function withStyle(string $style): self + { + $clone = clone $this; + $clone->style = $style; + + return $clone; + } + + public function withExplode(bool $explode): self + { + $clone = clone $this; + $clone->explode = $explode; + + return $clone; + } + + public function withAllowReserved(bool $allowReserved): self + { + $clone = clone $this; + $clone->allowReserved = $allowReserved; + + return $clone; + } + + public function withExample($example): self + { + $clone = clone $this; + $clone->example = $example; + + return $clone; + } + + public function withExamples(\ArrayObject $examples): self + { + $clone = clone $this; + $clone->examples = $examples; + + return $clone; + } + + public function withContent(\ArrayObject $content): self + { + $clone = clone $this; + $clone->content = $content; + + return $clone; + } +} diff --git a/src/OpenApi/Model/PathItem.php b/src/OpenApi/Model/PathItem.php new file mode 100644 index 00000000000..1285336fa74 --- /dev/null +++ b/src/OpenApi/Model/PathItem.php @@ -0,0 +1,220 @@ + + * + * 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\OpenApi\Model; + +final class PathItem +{ + use ExtensionTrait; + + private static $methods = ['GET', 'PUT', 'POST', 'DELETE', 'OPTIONS', 'HEAD', 'PATCH', 'TRACE']; + private $ref; + private $summary; + private $description; + private $get; + private $put; + private $post; + private $delete; + private $options; + private $head; + private $patch; + private $trace; + private $servers; + private $parameters; + + public function __construct(string $ref = null, string $summary = null, string $description = null, Operation $get = null, Operation $put = null, Operation $post = null, Operation $delete = null, Operation $options = null, Operation $head = null, Operation $patch = null, Operation $trace = null, array $servers = [], array $parameters = []) + { + $this->ref = $ref; + $this->summary = $summary; + $this->description = $description; + $this->get = $get; + $this->put = $put; + $this->post = $post; + $this->delete = $delete; + $this->options = $options; + $this->head = $head; + $this->patch = $patch; + $this->trace = $trace; + $this->servers = $servers; + $this->parameters = $parameters; + } + + public function getRef(): ?string + { + return $this->ref; + } + + public function getSummary(): ?string + { + return $this->summary; + } + + public function getDescription(): ?string + { + return $this->description; + } + + public function getGet(): ?Operation + { + return $this->get; + } + + public function getPut(): ?Operation + { + return $this->put; + } + + public function getPost(): ?Operation + { + return $this->post; + } + + public function getDelete(): ?Operation + { + return $this->delete; + } + + public function getOptions(): ?Operation + { + return $this->options; + } + + public function getHead(): ?Operation + { + return $this->head; + } + + public function getPatch(): ?Operation + { + return $this->patch; + } + + public function getTrace(): ?Operation + { + return $this->trace; + } + + public function getServers(): array + { + return $this->servers; + } + + public function getParameters(): array + { + return $this->parameters; + } + + public function withRef(string $ref): self + { + $clone = clone $this; + $clone->ref = $ref; + + return $clone; + } + + public function withSummary(string $summary): self + { + $clone = clone $this; + $clone->summary = $summary; + + return $clone; + } + + public function withDescription(string $description): self + { + $clone = clone $this; + $clone->description = $description; + + return $clone; + } + + public function withGet(Operation $get): self + { + $clone = clone $this; + $clone->get = $get; + + return $clone; + } + + public function withPut(Operation $put): self + { + $clone = clone $this; + $clone->put = $put; + + return $clone; + } + + public function withPost(Operation $post): self + { + $clone = clone $this; + $clone->post = $post; + + return $clone; + } + + public function withDelete(Operation $delete): self + { + $clone = clone $this; + $clone->delete = $delete; + + return $clone; + } + + public function withOptions(Operation $options): self + { + $clone = clone $this; + $clone->options = $options; + + return $clone; + } + + public function withHead(Operation $head): self + { + $clone = clone $this; + $clone->head = $head; + + return $clone; + } + + public function withPatch(Operation $patch): self + { + $clone = clone $this; + $clone->patch = $patch; + + return $clone; + } + + public function withTrace(Operation $trace): self + { + $clone = clone $this; + $clone->trace = $trace; + + return $clone; + } + + public function withServers(array $servers): self + { + $clone = clone $this; + $clone->servers = $servers; + + return $clone; + } + + public function withParameters(array $parameters): self + { + $clone = clone $this; + $clone->parameters = $parameters; + + return $clone; + } +} diff --git a/src/OpenApi/Model/Paths.php b/src/OpenApi/Model/Paths.php new file mode 100644 index 00000000000..c0b30a9a64c --- /dev/null +++ b/src/OpenApi/Model/Paths.php @@ -0,0 +1,34 @@ + + * + * 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\OpenApi\Model; + +final class Paths +{ + private $paths; + + public function addPath(string $path, PathItem $pathItem) + { + $this->paths[$path] = $pathItem; + } + + public function getPath(string $path): ?PathItem + { + return $this->paths[$path] ?? null; + } + + public function getPaths(): array + { + return $this->paths; + } +} diff --git a/src/OpenApi/Model/RequestBody.php b/src/OpenApi/Model/RequestBody.php new file mode 100644 index 00000000000..04235777a5e --- /dev/null +++ b/src/OpenApi/Model/RequestBody.php @@ -0,0 +1,69 @@ + + * + * 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\OpenApi\Model; + +final class RequestBody +{ + use ExtensionTrait; + + private $description; + private $content; + private $required; + + public function __construct(string $description = '', \ArrayObject $content = null, bool $required = false) + { + $this->description = $description; + $this->content = $content; + $this->required = $required; + } + + public function getDescription(): string + { + return $this->description; + } + + public function getContent(): \ArrayObject + { + return $this->content; + } + + public function getRequired(): bool + { + return $this->required; + } + + public function withDescription(string $description): self + { + $clone = clone $this; + $clone->description = $description; + + return $clone; + } + + public function withContent(\ArrayObject $content): self + { + $clone = clone $this; + $clone->content = $content; + + return $clone; + } + + public function withRequired(bool $required): self + { + $clone = clone $this; + $clone->required = $required; + + return $clone; + } +} diff --git a/src/OpenApi/Model/Response.php b/src/OpenApi/Model/Response.php new file mode 100644 index 00000000000..9bb2f84d353 --- /dev/null +++ b/src/OpenApi/Model/Response.php @@ -0,0 +1,84 @@ + + * + * 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\OpenApi\Model; + +final class Response +{ + use ExtensionTrait; + + private $description; + private $content; + private $headers; + private $links; + + public function __construct(string $description = '', \ArrayObject $content = null, \ArrayObject $headers = null, \ArrayObject $links = null) + { + $this->description = $description; + $this->content = $content; + $this->headers = $headers; + $this->links = $links; + } + + public function getDescription(): string + { + return $this->description; + } + + public function getContent(): ?\ArrayObject + { + return $this->content; + } + + public function getHeaders(): ?\ArrayObject + { + return $this->headers; + } + + public function getLinks(): ?\ArrayObject + { + return $this->links; + } + + public function withDescription(string $description): self + { + $clone = clone $this; + $clone->description = $description; + + return $clone; + } + + public function withContent(\ArrayObject $content): self + { + $clone = clone $this; + $clone->content = $content; + + return $clone; + } + + public function withHeaders(\ArrayObject $headers): self + { + $clone = clone $this; + $clone->headers = $headers; + + return $clone; + } + + public function withLinks(\ArrayObject $links): self + { + $clone = clone $this; + $clone->links = $links; + + return $clone; + } +} diff --git a/src/OpenApi/Model/Schema.php b/src/OpenApi/Model/Schema.php new file mode 100644 index 00000000000..5b9060238f7 --- /dev/null +++ b/src/OpenApi/Model/Schema.php @@ -0,0 +1,171 @@ + + * + * 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\OpenApi\Model; + +use ApiPlatform\Core\JsonSchema\Schema as JsonSchema; + +final class Schema extends \ArrayObject +{ + use ExtensionTrait; + + private $nullable; + private $discriminator; + private $readOnly; + private $writeOnly; + private $xml; + private $externalDocs; + private $example; + private $deprecated; + private $schema; + + public function __construct(bool $nullable = false, $discriminator = null, bool $readOnly = false, bool $writeOnly = false, string $xml = null, $externalDocs = null, $example = null, bool $deprecated = false) + { + $this->nullable = $nullable; + $this->discriminator = $discriminator; + $this->readOnly = $readOnly; + $this->writeOnly = $writeOnly; + $this->xml = $xml; + $this->externalDocs = $externalDocs; + $this->example = $example; + $this->deprecated = $deprecated; + $this->schema = new JsonSchema(); + + parent::__construct([]); + } + + public function setDefinitions(array $definitions) + { + $this->schema->setDefinitions(new \ArrayObject($definitions)); + } + + /** + * {@inheritdoc} + */ + public function getArrayCopy(): array + { + $schema = parent::getArrayCopy(); + unset($schema['schema']); + + return $schema; + } + + public function getDefinitions(): \ArrayObject + { + return new \ArrayObject(array_merge($this->schema->getArrayCopy(true), $this->getArrayCopy())); + } + + public function getNullable(): bool + { + return $this->nullable; + } + + public function getDiscriminator() + { + return $this->discriminator; + } + + public function getReadOnly(): bool + { + return $this->readOnly; + } + + public function getWriteOnly(): bool + { + return $this->writeOnly; + } + + public function getXml(): string + { + return $this->xml; + } + + public function getExternalDocs() + { + return $this->externalDocs; + } + + public function getExample() + { + return $this->example; + } + + public function getDeprecated(): bool + { + return $this->deprecated; + } + + public function withNullable(bool $nullable): self + { + $clone = clone $this; + $clone->nullable = $nullable; + + return $clone; + } + + public function withDiscriminator($discriminator): self + { + $clone = clone $this; + $clone->discriminator = $discriminator; + + return $clone; + } + + public function withReadOnly(bool $readOnly): self + { + $clone = clone $this; + $clone->readOnly = $readOnly; + + return $clone; + } + + public function withWriteOnly(bool $writeOnly): self + { + $clone = clone $this; + $clone->writeOnly = $writeOnly; + + return $clone; + } + + public function withXml(string $xml): self + { + $clone = clone $this; + $clone->xml = $xml; + + return $clone; + } + + public function withExternalDocs($externalDocs): self + { + $clone = clone $this; + $clone->externalDocs = $externalDocs; + + return $clone; + } + + public function withExample($example): self + { + $clone = clone $this; + $clone->example = $example; + + return $clone; + } + + public function withDeprecated(bool $deprecated): self + { + $clone = clone $this; + $clone->deprecated = $deprecated; + + return $clone; + } +} diff --git a/src/OpenApi/Model/SecurityScheme.php b/src/OpenApi/Model/SecurityScheme.php new file mode 100644 index 00000000000..385e4e028c5 --- /dev/null +++ b/src/OpenApi/Model/SecurityScheme.php @@ -0,0 +1,144 @@ + + * + * 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\OpenApi\Model; + +final class SecurityScheme +{ + use ExtensionTrait; + + private $type; + private $description; + private $name; + private $in; + private $scheme; + private $bearerFormat; + private $flows; + private $openIdConnectUrl; + + public function __construct(string $type = null, string $description = '', string $name = null, string $in = null, string $scheme = null, string $bearerFormat = null, OAuthFlows $flows = null, string $openIdConnectUrl = null) + { + $this->type = $type; + $this->description = $description; + $this->name = $name; + $this->in = $in; + $this->scheme = $scheme; + $this->bearerFormat = $bearerFormat; + $this->flows = $flows; + $this->openIdConnectUrl = $openIdConnectUrl; + } + + public function getType(): string + { + return $this->type; + } + + public function getDescription(): string + { + return $this->description; + } + + public function getName(): ?string + { + return $this->name; + } + + public function getIn(): ?string + { + return $this->in; + } + + public function getScheme(): string + { + return $this->scheme; + } + + public function getBearerFormat(): ?string + { + return $this->bearerFormat; + } + + public function getFlows(): ?OAuthFlows + { + return $this->flows; + } + + public function getOpenIdConnectUrl(): ?string + { + return $this->openIdConnectUrl; + } + + public function withType(string $type): self + { + $clone = clone $this; + $clone->type = $type; + + return $clone; + } + + public function withDescription(string $description): self + { + $clone = clone $this; + $clone->description = $description; + + return $clone; + } + + public function withName(string $name): self + { + $clone = clone $this; + $clone->name = $name; + + return $clone; + } + + public function withIn(string $in): self + { + $clone = clone $this; + $clone->in = $in; + + return $clone; + } + + public function withScheme(string $scheme): self + { + $clone = clone $this; + $clone->scheme = $scheme; + + return $clone; + } + + public function withBearerFormat(string $bearerFormat): self + { + $clone = clone $this; + $clone->bearerFormat = $bearerFormat; + + return $clone; + } + + public function withFlows(OAuthFlows $flows): self + { + $clone = clone $this; + $clone->flows = $flows; + + return $clone; + } + + public function withOpenIdConnectUrl(string $openIdConnectUrl): self + { + $clone = clone $this; + $clone->openIdConnectUrl = $openIdConnectUrl; + + return $clone; + } +} diff --git a/src/OpenApi/Model/Server.php b/src/OpenApi/Model/Server.php new file mode 100644 index 00000000000..852afd5c9ad --- /dev/null +++ b/src/OpenApi/Model/Server.php @@ -0,0 +1,69 @@ + + * + * 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\OpenApi\Model; + +final class Server +{ + use ExtensionTrait; + + private $url; + private $description; + private $variables; + + public function __construct(string $url, string $description = '', \ArrayObject $variables = null) + { + $this->url = $url; + $this->description = $description; + $this->variables = $variables; + } + + public function getUrl(): string + { + return $this->url; + } + + public function getDescription(): string + { + return $this->description; + } + + public function getVariables(): ?\ArrayObject + { + return $this->variables; + } + + public function withUrl(string $url): self + { + $clone = clone $this; + $clone->url = $url; + + return $clone; + } + + public function withDescription(string $description): self + { + $clone = clone $this; + $clone->description = $description; + + return $clone; + } + + public function withVariables(\ArrayObject $variables): self + { + $clone = clone $this; + $clone->variables = $variables; + + return $clone; + } +} diff --git a/src/OpenApi/OpenApi.php b/src/OpenApi/OpenApi.php new file mode 100644 index 00000000000..94c50377671 --- /dev/null +++ b/src/OpenApi/OpenApi.php @@ -0,0 +1,152 @@ + + * + * 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\OpenApi; + +use ApiPlatform\Core\Documentation\DocumentationInterface; +use ApiPlatform\Core\OpenApi\Model\Components; +use ApiPlatform\Core\OpenApi\Model\ExtensionTrait; +use ApiPlatform\Core\OpenApi\Model\Info; +use ApiPlatform\Core\OpenApi\Model\Paths; + +final class OpenApi implements DocumentationInterface +{ + use ExtensionTrait; + + public const VERSION = '3.0.3'; + + private $openapi; + private $info; + private $servers; + private $paths; + private $components; + private $security; + private $tags; + private $externalDocs; + + public function __construct(Info $info, array $servers = [], Paths $paths, Components $components = null, array $security = [], array $tags = [], $externalDocs = null) + { + $this->openapi = self::VERSION; + $this->info = $info; + $this->servers = $servers; + $this->paths = $paths; + $this->components = $components; + $this->security = $security; + $this->tags = $tags; + $this->externalDocs = $externalDocs; + } + + public function getOpenapi(): string + { + return $this->openapi; + } + + public function getInfo(): Info + { + return $this->info; + } + + public function getServers(): array + { + return $this->servers; + } + + public function getPaths() + { + return $this->paths; + } + + public function getComponents(): Components + { + return $this->components; + } + + public function getSecurity(): array + { + return $this->security; + } + + public function getTags(): array + { + return $this->tags; + } + + public function getExternalDocs(): ?array + { + return $this->externalDocs; + } + + public function withOpenapi(string $openapi): self + { + $clone = clone $this; + $clone->openapi = $openapi; + + return $clone; + } + + public function withInfo(Info $info): self + { + $clone = clone $this; + $clone->info = $info; + + return $clone; + } + + public function withServers(array $servers): self + { + $clone = clone $this; + $clone->servers = $servers; + + return $clone; + } + + public function withPaths(Paths $paths): self + { + $clone = clone $this; + $clone->paths = $paths; + + return $clone; + } + + public function withComponents(Components $components): self + { + $clone = clone $this; + $clone->components = $components; + + return $clone; + } + + public function withSecurity(array $security): self + { + $clone = clone $this; + $clone->security = $security; + + return $clone; + } + + public function withTags(array $tags): self + { + $clone = clone $this; + $clone->tags = $tags; + + return $clone; + } + + public function withExternalDocs(array $externalDocs): self + { + $clone = clone $this; + $clone->externalDocs = $externalDocs; + + return $clone; + } +} diff --git a/src/OpenApi/Options.php b/src/OpenApi/Options.php new file mode 100644 index 00000000000..c1b35e2e719 --- /dev/null +++ b/src/OpenApi/Options.php @@ -0,0 +1,99 @@ + + * + * 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\OpenApi; + +final class Options +{ + private $title; + private $description; + private $version; + private $oAuthEnabled; + private $oAuthType; + private $oAuthFlow; + private $oAuthTokenUrl; + private $oAuthAuthorizationUrl; + private $oAuthRefreshUrl; + private $oAuthScopes; + private $apiKeys; + + public function __construct(string $title, string $description = '', string $version = '', bool $oAuthEnabled = false, string $oAuthType = '', string $oAuthFlow = '', string $oAuthTokenUrl = '', string $oAuthAuthorizationUrl = '', string $oAuthRefreshUrl = '', array $oAuthScopes = [], array $apiKeys = []) + { + $this->title = $title; + $this->description = $description; + $this->version = $version; + $this->oAuthEnabled = $oAuthEnabled; + $this->oAuthType = $oAuthType; + $this->oAuthFlow = $oAuthFlow; + $this->oAuthTokenUrl = $oAuthTokenUrl; + $this->oAuthAuthorizationUrl = $oAuthAuthorizationUrl; + $this->oAuthRefreshUrl = $oAuthRefreshUrl; + $this->oAuthScopes = $oAuthScopes; + $this->apiKeys = $apiKeys; + } + + public function getTitle(): string + { + return $this->title; + } + + public function getDescription(): string + { + return $this->description; + } + + public function getVersion(): string + { + return $this->version; + } + + public function getOAuthEnabled(): bool + { + return $this->oAuthEnabled; + } + + public function getOAuthType(): string + { + return $this->oAuthType; + } + + public function getOAuthFlow(): string + { + return $this->oAuthFlow; + } + + public function getOAuthTokenUrl(): string + { + return $this->oAuthTokenUrl; + } + + public function getOAuthAuthorizationUrl(): string + { + return $this->oAuthAuthorizationUrl; + } + + public function getOAuthRefreshUrl(): string + { + return $this->oAuthRefreshUrl; + } + + public function getOAuthScopes(): array + { + return $this->oAuthScopes; + } + + public function getApiKeys(): array + { + return $this->apiKeys; + } +} diff --git a/src/OpenApi/Serializer/OpenApiNormalizer.php b/src/OpenApi/Serializer/OpenApiNormalizer.php new file mode 100644 index 00000000000..975e340c14c --- /dev/null +++ b/src/OpenApi/Serializer/OpenApiNormalizer.php @@ -0,0 +1,91 @@ + + * + * 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\OpenApi\Serializer; + +use ApiPlatform\Core\OpenApi\OpenApi; +use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer; +use Symfony\Component\Serializer\Normalizer\CacheableSupportsMethodInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + +/** + * Generates an OpenAPI v3 specification. + */ +final class OpenApiNormalizer implements NormalizerInterface, CacheableSupportsMethodInterface +{ + public const FORMAT = 'json'; + public const OPEN_API_PRESERVE_EMPTY_OBJECTS = 'open_api_preserve_empty_objects'; + private const EXTENSION_PROPERTIES_KEY = 'extensionProperties'; + + private $decorated; + + public function __construct(NormalizerInterface $decorated) + { + $this->decorated = $decorated; + } + + /** + * {@inheritdoc} + */ + public function normalize($object, $format = null, array $context = []): array + { + $context[AbstractObjectNormalizer::PRESERVE_EMPTY_OBJECTS] = true; + $context[AbstractObjectNormalizer::SKIP_NULL_VALUES] = true; + + return $this->recursiveClean($this->decorated->normalize($object, $format, $context)); + } + + private function recursiveClean($data): array + { + foreach ($data as $key => $value) { + if (self::EXTENSION_PROPERTIES_KEY === $key) { + foreach ($data[self::EXTENSION_PROPERTIES_KEY] as $extensionPropertyKey => $extensionPropertyValue) { + $data[$extensionPropertyKey] = $extensionPropertyValue; + } + continue; + } + + // Side effect of using getPaths(): Paths which itself contains the array + if ('paths' === $key) { + $value = $data['paths'] = $data['paths']['paths']; + unset($data['paths']['paths']); + } + + if (\is_array($value)) { + $data[$key] = $this->recursiveClean($value); + // arrays must stay even if empty + continue; + } + } + + unset($data[self::EXTENSION_PROPERTIES_KEY]); + + return $data; + } + + /** + * {@inheritdoc} + */ + public function supportsNormalization($data, $format = null): bool + { + return self::FORMAT === $format && $data instanceof OpenApi; + } + + /** + * {@inheritdoc} + */ + public function hasCacheableSupportsMethod(): bool + { + return true; + } +} diff --git a/src/Swagger/Serializer/DocumentationNormalizer.php b/src/Swagger/Serializer/DocumentationNormalizer.php index 835fe75a740..8c88ad034ad 100644 --- a/src/Swagger/Serializer/DocumentationNormalizer.php +++ b/src/Swagger/Serializer/DocumentationNormalizer.php @@ -85,6 +85,7 @@ final class DocumentationNormalizer implements NormalizerInterface, CacheableSup private $paginationClientEnabledParameterName; private $formats; private $formatsProvider; + /** * @var SchemaFactoryInterface */ diff --git a/tests/Bridge/Symfony/Bundle/Command/OpenApiCommandTest.php b/tests/Bridge/Symfony/Bundle/Command/OpenApiCommandTest.php new file mode 100644 index 00000000000..a9df5225a0e --- /dev/null +++ b/tests/Bridge/Symfony/Bundle/Command/OpenApiCommandTest.php @@ -0,0 +1,112 @@ + + * + * 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\Tests\Bridge\Symfony\Bundle\Command; + +use ApiPlatform\Core\OpenApi\OpenApi; +use Symfony\Bundle\FrameworkBundle\Console\Application; +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Symfony\Component\Console\Tester\ApplicationTester; +use Symfony\Component\Yaml\Exception\ParseException; +use Symfony\Component\Yaml\Yaml; + +/** + * @author Amrouche Hamza + */ +class OpenApiCommandTest extends KernelTestCase +{ + /** + * @var ApplicationTester + */ + private $tester; + + protected function setUp(): void + { + self::bootKernel(); + + $application = new Application(static::$kernel); + $application->setCatchExceptions(false); + $application->setAutoExit(false); + + $this->tester = new ApplicationTester($application); + } + + public function testExecute() + { + $this->tester->run(['command' => 'api:openapi:export']); + + $this->assertJson($this->tester->getDisplay()); + } + + public function testExecuteWithYaml() + { + $this->tester->run(['command' => 'api:openapi:export', '--yaml' => true]); + + $result = $this->tester->getDisplay(); + $this->assertYaml($result); + + $expected = <<assertStringContainsString($expected, $result, 'nested object should be present.'); + + $expected = <<assertStringContainsString($expected, $result, 'arrays should be correctly formatted.'); + $this->assertStringContainsString('openapi: '.OpenApi::VERSION, $result); + + $expected = <<assertStringContainsString($expected, $result, 'multiline formatting must be preserved (using literal style).'); + } + + public function testWriteToFile() + { + /** @var string $tmpFile */ + $tmpFile = tempnam(sys_get_temp_dir(), 'test_write_to_file'); + + $this->tester->run(['command' => 'api:openapi:export', '--output' => $tmpFile]); + + $this->assertJson((string) @file_get_contents($tmpFile)); + @unlink($tmpFile); + } + + /** + * @param string $data + */ + private function assertYaml($data) + { + try { + Yaml::parse($data); + } catch (ParseException $exception) { + $this->fail('Is not valid YAML: '.$exception->getMessage()); + } + $this->addToAssertionCount(1); + } +} diff --git a/tests/Bridge/Symfony/Bundle/Command/SwaggerCommandTest.php b/tests/Bridge/Symfony/Bundle/Command/SwaggerCommandTest.php index e290d87768c..69ba05ee03d 100644 --- a/tests/Bridge/Symfony/Bundle/Command/SwaggerCommandTest.php +++ b/tests/Bridge/Symfony/Bundle/Command/SwaggerCommandTest.php @@ -41,6 +41,10 @@ protected function setUp(): void $this->tester = new ApplicationTester($application); } + /** + * @group legacy + * @expectedDeprecation The command "api:swagger:export" is deprecated for the spec version 3 use "api:openapi:export". + */ public function testExecuteWithAliasVersion3() { $this->tester->run(['command' => 'api:swagger:export', '--spec-version' => 3]); @@ -48,13 +52,10 @@ public function testExecuteWithAliasVersion3() $this->assertJson($this->tester->getDisplay()); } - public function testExecuteOpenApiVersion2() - { - $this->tester->run(['command' => 'api:openapi:export']); - - $this->assertJson($this->tester->getDisplay()); - } - + /** + * @group legacy + * @expectedDeprecation The command "api:swagger:export" is deprecated for the spec version 3 use "api:openapi:export". + */ public function testExecuteWithYamlVersion3() { $this->tester->run(['command' => 'api:swagger:export', '--yaml' => true, '--spec-version' => 3]); @@ -93,20 +94,11 @@ public function testExecuteWithYamlVersion3() $this->assertStringContainsString($expected, $result, 'multiline formatting must be preserved (using literal style).'); } - public function testExecuteOpenApiVersion2WithYaml() - { - $this->tester->run(['command' => 'api:openapi:export', '--yaml' => true]); - - $result = $this->tester->getDisplay(); - $this->assertYaml($result); - $this->assertStringContainsString("swagger: '2.0'", $result); - } - public function testExecuteWithBadArguments() { $this->expectException(InvalidOptionException::class); $this->expectExceptionMessage('This tool only supports versions 2, 3 of the OpenAPI specification ("foo" given).'); - $this->tester->run(['command' => 'api:openapi:export', '--spec-version' => 'foo', '--yaml' => true]); + $this->tester->run(['command' => 'api:swagger:export', '--spec-version' => 'foo', '--yaml' => true]); } public function testWriteToFile() diff --git a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php index fd3ad6ee7fe..2271044208c 100644 --- a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php +++ b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php @@ -61,6 +61,7 @@ use ApiPlatform\Core\DataProvider\CollectionDataProviderInterface; use ApiPlatform\Core\DataProvider\ItemDataProviderInterface; use ApiPlatform\Core\DataProvider\Pagination; +use ApiPlatform\Core\DataProvider\PaginationOptions; use ApiPlatform\Core\DataProvider\SubresourceDataProviderInterface; use ApiPlatform\Core\DataTransformer\DataTransformerInterface; use ApiPlatform\Core\Exception\FilterValidationException; @@ -77,6 +78,9 @@ use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; +use ApiPlatform\Core\OpenApi\Factory\OpenApiFactoryInterface; +use ApiPlatform\Core\OpenApi\Options; +use ApiPlatform\Core\OpenApi\Serializer\OpenApiNormalizer; use ApiPlatform\Core\Security\ResourceAccessCheckerInterface; use ApiPlatform\Core\Serializer\Filter\GroupFilter; use ApiPlatform\Core\Serializer\Filter\PropertyFilter; @@ -960,6 +964,7 @@ private function getPartialContainerBuilderProphecy($configuration = null) 'api_platform.operation_path_resolver.generator', 'api_platform.operation_path_resolver.underscore', 'api_platform.pagination', + 'api_platform.pagination_options', 'api_platform.path_segment_name_generator.underscore', 'api_platform.path_segment_name_generator.dash', 'api_platform.resource_class_resolver', @@ -1019,6 +1024,7 @@ private function getPartialContainerBuilderProphecy($configuration = null) SerializerContextBuilderInterface::class => 'api_platform.serializer.context_builder', SubresourceDataProviderInterface::class => 'api_platform.subresource_data_provider', UrlGeneratorInterface::class => 'api_platform.router', + PaginationOptions::class => 'api_platform.pagination_options', ]; foreach ($aliases as $alias => $service) { @@ -1143,6 +1149,7 @@ private function getBaseContainerBuilderProphecy(array $doctrineIntegrationsToLo 'api_platform.oauth.flow' => 'application', 'api_platform.oauth.tokenUrl' => '/oauth/v2/token', 'api_platform.oauth.authorizationUrl' => '/oauth/v2/auth', + 'api_platform.oauth.refreshUrl' => '/oauth/v2/refresh', 'api_platform.oauth.scopes' => [], 'api_platform.enable_swagger_ui' => true, 'api_platform.enable_re_doc' => true, @@ -1319,6 +1326,11 @@ private function getBaseContainerBuilderProphecy(array $doctrineIntegrationsToLo $definitions[] = 'api_platform.json_schema.type_factory'; $definitions[] = 'api_platform.json_schema.schema_factory'; $definitions[] = 'api_platform.json_schema.json_schema_generate_command'; + $definitions[] = 'api_platform.openapi.options'; + $definitions[] = 'api_platform.openapi.normalizer'; + $definitions[] = 'api_platform.openapi.normalizer.api_gateway'; + $definitions[] = 'api_platform.openapi.factory'; + $definitions[] = 'api_platform.openapi.command'; } // has jsonld @@ -1390,6 +1402,9 @@ private function getBaseContainerBuilderProphecy(array $doctrineIntegrationsToLo $aliases += [ TypeFactoryInterface::class => 'api_platform.json_schema.type_factory', SchemaFactoryInterface::class => 'api_platform.json_schema.schema_factory', + Options::class => 'api_platform.openapi.options', + OpenApiNormalizer::class => 'api_platform.openapi.normalizer', + OpenApiFactoryInterface::class => 'api_platform.openapi.factory', ]; } diff --git a/tests/Bridge/Symfony/Bundle/DependencyInjection/ConfigurationTest.php b/tests/Bridge/Symfony/Bundle/DependencyInjection/ConfigurationTest.php index 57510a68ca1..2276679a860 100644 --- a/tests/Bridge/Symfony/Bundle/DependencyInjection/ConfigurationTest.php +++ b/tests/Bridge/Symfony/Bundle/DependencyInjection/ConfigurationTest.php @@ -144,6 +144,7 @@ private function runDefaultConfigTests(array $doctrineIntegrationsToLoad = ['orm 'flow' => 'application', 'tokenUrl' => '/oauth/v2/token', 'authorizationUrl' => '/oauth/v2/auth', + 'refreshUrl' => '/oauth/v2/refresh', 'scopes' => [], ], 'swagger' => [ diff --git a/tests/Documentation/Action/DocumentationActionTest.php b/tests/Documentation/Action/DocumentationActionTest.php index 3d7bbd3efe7..728fd06a92e 100644 --- a/tests/Documentation/Action/DocumentationActionTest.php +++ b/tests/Documentation/Action/DocumentationActionTest.php @@ -18,6 +18,10 @@ use ApiPlatform\Core\Documentation\Documentation; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; use ApiPlatform\Core\Metadata\Resource\ResourceNameCollection; +use ApiPlatform\Core\OpenApi\Factory\OpenApiFactoryInterface; +use ApiPlatform\Core\OpenApi\Model\Info; +use ApiPlatform\Core\OpenApi\Model\Paths; +use ApiPlatform\Core\OpenApi\OpenApi; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; @@ -29,7 +33,11 @@ */ class DocumentationActionTest extends TestCase { - public function testyDocumentationAction(): void + /** + * @group legacy + * @expectedDeprecation Not passing an instance of "ApiPlatform\Core\OpenApi\Factory\OpenApiFactoryInterface" as 7th parameter of the constructor of "ApiPlatform\Core\Documentation\Action\DocumentationAction" is deprecated since API Platform 2.6 + */ + public function testDocumentationAction(): void { $requestProphecy = $this->prophesize(Request::class); $attributesProphecy = $this->prophesize(ParameterBagInterface::class); @@ -81,4 +89,48 @@ public function testDocumentationActionFormatDeprecation() $resourceNameCollectionFactoryProphecy->create()->willReturn(new ResourceNameCollection(['dummies'])); new DocumentationAction($resourceNameCollectionFactoryProphecy->reveal(), '', '', '', ['formats' => ['jsonld' => 'application/ld+json']]); } + + public function testDocumentationActionV2(): void + { + $openApiFactoryProphecy = $this->prophesize(OpenApiFactoryInterface::class); + $requestProphecy = $this->prophesize(Request::class); + $attributesProphecy = $this->prophesize(ParameterBagInterface::class); + $queryProphecy = $this->prophesize(ParameterBag::class); + $resourceNameCollectionFactoryProphecy = $this->prophesize(ResourceNameCollectionFactoryInterface::class); + $resourceNameCollectionFactoryProphecy->create()->willReturn(new ResourceNameCollection(['dummies'])); + $requestProphecy->attributes = $attributesProphecy->reveal(); + $requestProphecy->query = $queryProphecy->reveal(); + $requestProphecy->getBaseUrl()->willReturn('/api')->shouldBeCalledTimes(1); + $queryProphecy->getBoolean('api_gateway')->willReturn(true)->shouldBeCalledTimes(1); + $queryProphecy->getInt('spec_version', 2)->willReturn(2)->shouldBeCalledTimes(1); + $attributesProphecy->all()->willReturn(['_api_normalization_context' => ['foo' => 'bar', 'base_url' => '/api', 'api_gateway' => true, 'spec_version' => 2]])->shouldBeCalledTimes(1); + $attributesProphecy->get('_api_normalization_context', [])->willReturn(['foo' => 'bar'])->shouldBeCalledTimes(1); + $attributesProphecy->set('_api_normalization_context', ['foo' => 'bar', 'base_url' => '/api', 'api_gateway' => true, 'spec_version' => 2])->shouldBeCalledTimes(1); + + $documentation = new DocumentationAction($resourceNameCollectionFactoryProphecy->reveal(), 'My happy hippie api', 'lots of chocolate', '1.0.0', null, [2, 3], $openApiFactoryProphecy->reveal()); + $this->assertEquals(new Documentation(new ResourceNameCollection(['dummies']), 'My happy hippie api', 'lots of chocolate', '1.0.0'), $documentation($requestProphecy->reveal())); + } + + public function testDocumentationActionV3(): void + { + $openApi = new OpenApi(new Info('my api', '1.0.0'), [], new Paths()); + $openApiFactoryProphecy = $this->prophesize(OpenApiFactoryInterface::class); + $openApiFactoryProphecy->__invoke(Argument::any())->shouldBeCalled()->willReturn($openApi); + $requestProphecy = $this->prophesize(Request::class); + $attributesProphecy = $this->prophesize(ParameterBagInterface::class); + $queryProphecy = $this->prophesize(ParameterBag::class); + $resourceNameCollectionFactoryProphecy = $this->prophesize(ResourceNameCollectionFactoryInterface::class); + $resourceNameCollectionFactoryProphecy->create()->willReturn(new ResourceNameCollection(['dummies'])); + $requestProphecy->attributes = $attributesProphecy->reveal(); + $requestProphecy->query = $queryProphecy->reveal(); + $requestProphecy->getBaseUrl()->willReturn('/api')->shouldBeCalledTimes(1); + $queryProphecy->getBoolean('api_gateway')->willReturn(true)->shouldBeCalledTimes(1); + $queryProphecy->getInt('spec_version', 2)->willReturn(3)->shouldBeCalledTimes(1); + $attributesProphecy->all()->willReturn(['_api_normalization_context' => ['foo' => 'bar', 'base_url' => '/api', 'api_gateway' => true, 'spec_version' => 3]])->shouldBeCalledTimes(1); + $attributesProphecy->get('_api_normalization_context', [])->willReturn(['foo' => 'bar'])->shouldBeCalledTimes(1); + $attributesProphecy->set('_api_normalization_context', ['foo' => 'bar', 'base_url' => '/api', 'api_gateway' => true, 'spec_version' => 3])->shouldBeCalledTimes(1); + + $documentation = new DocumentationAction($resourceNameCollectionFactoryProphecy->reveal(), 'My happy hippie api', 'lots of chocolate', '1.0.0', null, [2, 3], $openApiFactoryProphecy->reveal()); + $this->assertInstanceOf(OpenApi::class, $documentation($requestProphecy->reveal())); + } } diff --git a/tests/OpenApi/Factory/OpenApiFactoryTest.php b/tests/OpenApi/Factory/OpenApiFactoryTest.php new file mode 100644 index 00000000000..f960dc3a113 --- /dev/null +++ b/tests/OpenApi/Factory/OpenApiFactoryTest.php @@ -0,0 +1,672 @@ + + * + * 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\Tests\OpenApi\Factory; + +use ApiPlatform\Core\Bridge\Symfony\Routing\RouterOperationPathResolver; +use ApiPlatform\Core\DataProvider\PaginationOptions; +use ApiPlatform\Core\JsonSchema\Schema; +use ApiPlatform\Core\JsonSchema\SchemaFactory; +use ApiPlatform\Core\JsonSchema\TypeFactory; +use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; +use ApiPlatform\Core\Metadata\Property\PropertyMetadata; +use ApiPlatform\Core\Metadata\Property\PropertyNameCollection; +use ApiPlatform\Core\Metadata\Property\SubresourceMetadata; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; +use ApiPlatform\Core\Metadata\Resource\ResourceNameCollection; +use ApiPlatform\Core\OpenApi\Factory\OpenApiFactory; +use ApiPlatform\Core\OpenApi\Model; +use ApiPlatform\Core\OpenApi\OpenApi; +use ApiPlatform\Core\OpenApi\Options; +use ApiPlatform\Core\Operation\Factory\SubresourceOperationFactory; +use ApiPlatform\Core\Operation\Factory\SubresourceOperationFactoryInterface; +use ApiPlatform\Core\Operation\UnderscorePathSegmentNameGenerator; +use ApiPlatform\Core\PathResolver\CustomOperationPathResolver; +use ApiPlatform\Core\PathResolver\OperationPathResolver; +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 PHPUnit\Framework\TestCase; +use Prophecy\Argument; +use Psr\Container\ContainerInterface; +use Symfony\Component\PropertyInfo\Type; +use Symfony\Component\Routing\Route; +use Symfony\Component\Routing\RouteCollection; +use Symfony\Component\Routing\RouterInterface; +use Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter; + +class OpenApiFactoryTest extends TestCase +{ + private const OPERATION_FORMATS = [ + 'input_formats' => ['jsonld' => ['application/ld+json']], + 'output_formats' => ['jsonld' => ['application/ld+json']], + ]; + + public function testInvoke(): void + { + $dummyMetadata = new ResourceMetadata( + 'Dummy', + 'This is a dummy.', + 'http://schema.example.com/Dummy', + [ + 'get' => ['method' => 'GET'] + self::OPERATION_FORMATS, + 'put' => ['method' => 'PUT'] + self::OPERATION_FORMATS, + 'delete' => ['method' => 'DELETE'] + self::OPERATION_FORMATS, + 'custom' => ['method' => 'HEAD', 'path' => '/foo/{id}', 'openapi_context' => ['description' => 'Custom description']] + self::OPERATION_FORMATS, + 'formats' => ['method' => 'PUT', 'path' => '/formatted/{id}', 'output_formats' => ['json' => ['application/json'], 'csv' => ['text/csv']], 'input_formats' => ['json' => ['application/json'], 'csv' => ['text/csv']]], + ], + [ + 'get' => ['method' => 'GET'] + self::OPERATION_FORMATS, + 'post' => ['method' => 'POST'] + self::OPERATION_FORMATS, + 'filtered' => ['method' => 'GET', 'filters' => ['f1', 'f2', 'f3', 'f4', 'f5'], 'path' => '/filtered'] + self::OPERATION_FORMATS, + 'paginated' => ['method' => 'GET', 'pagination_client_enabled' => true, 'pagination_client_items_per_page' => true, 'pagination_items_per_page' => 20, 'pagination_maximum_items_per_page' => 80, 'path' => '/paginated'] + self::OPERATION_FORMATS, + ], + ['pagination_client_items_per_page' => true] + ); + + $subresourceOperationFactoryProphecy = $this->prophesize(SubresourceOperationFactoryInterface::class); + $subresourceOperationFactoryProphecy->create(Argument::any())->willReturn([]); + + $resourceNameCollectionFactoryProphecy = $this->prophesize(ResourceNameCollectionFactoryInterface::class); + $resourceNameCollectionFactoryProphecy->create()->shouldBeCalled()->willReturn(new ResourceNameCollection([Dummy::class])); + + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataFactoryProphecy->create(Dummy::class)->shouldBeCalled()->willReturn($dummyMetadata); + + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::any())->shouldBeCalled()->willReturn(new PropertyNameCollection(['id', 'name', 'description', 'dummyDate', 'enum'])); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'id', Argument::any())->shouldBeCalled()->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_INT), 'This is an id.', true, false, null, null, null, true, null, null, null, null, null, null, null, ['minLength' => 3, 'maxLength' => 20, 'pattern' => '^dummyPattern$'])); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::any())->shouldBeCalled()->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING), 'This is a name.', true, true, true, true, false, false, null, null, [], null, null, null, null)); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'description', Argument::any())->shouldBeCalled()->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING), 'This is an initializable but not writable property.', true, false, true, true, false, false, null, null, [], null, true)); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'dummyDate', Argument::any())->shouldBeCalled()->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_OBJECT, true, \DateTime::class), 'This is a \DateTimeInterface object.', true, true, true, true, false, false, null, null, [])); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'enum', Argument::any())->shouldBeCalled()->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING), 'This is an enum.', true, true, true, true, false, false, null, null, ['openapi_context' => ['type' => 'string', 'enum' => ['one', 'two'], 'example' => 'one']])); + + $operationPathResolver = new CustomOperationPathResolver(new OperationPathResolver(new UnderscorePathSegmentNameGenerator())); + + $filterLocatorProphecy = $this->prophesize(ContainerInterface::class); + $filters = [ + 'f1' => new DummyFilter(['name' => [ + 'property' => 'name', + 'type' => 'string', + 'required' => true, + 'strategy' => 'exact', + 'openapi' => ['example' => 'bar', 'deprecated' => true, 'allowEmptyValue' => true, 'allowReserved' => true], + ]]), + 'f2' => new DummyFilter(['ha' => [ + 'property' => 'foo', + 'type' => 'int', + 'required' => false, + 'strategy' => 'partial', + ]]), + 'f3' => new DummyFilter(['toto' => [ + 'property' => 'name', + 'type' => 'array', + 'is_collection' => true, + 'required' => true, + 'strategy' => 'exact', + ]]), + 'f4' => new DummyFilter(['order[name]' => [ + 'property' => 'name', + 'type' => 'string', + 'required' => false, + 'schema' => [ + 'type' => 'string', + 'enum' => ['asc', 'desc'], + ], + ]]), + ]; + + foreach ($filters as $filterId => $filter) { + $filterLocatorProphecy->has($filterId)->willReturn(true)->shouldBeCalled(); + $filterLocatorProphecy->get($filterId)->willReturn($filter)->shouldBeCalled(); + } + + $filterLocatorProphecy->has('f5')->willReturn(false)->shouldBeCalled(); + + $resourceMetadataFactory = $resourceMetadataFactoryProphecy->reveal(); + $propertyNameCollectionFactory = $propertyNameCollectionFactoryProphecy->reveal(); + + $propertyMetadataFactory = $propertyMetadataFactoryProphecy->reveal(); + + $typeFactory = new TypeFactory(); + $schemaFactory = new SchemaFactory($typeFactory, $resourceMetadataFactory, $propertyNameCollectionFactory, $propertyMetadataFactory, new CamelCaseToSnakeCaseNameConverter()); + $typeFactory->setSchemaFactory($schemaFactory); + + $factory = new OpenApiFactory( + $resourceNameCollectionFactoryProphecy->reveal(), + $resourceMetadataFactory, + $propertyNameCollectionFactory, + $propertyMetadataFactory, + $schemaFactory, + $typeFactory, + $operationPathResolver, + $filterLocatorProphecy->reveal(), + $subresourceOperationFactoryProphecy->reveal(), + [], + new Options('Test API', 'This is a test API.', '1.2.3', true, 'oauth2', 'authorizationCode', '/oauth/v2/token', '/oauth/v2/auth', '/oauth/v2/refresh', ['scope param'], [ + 'header' => [ + 'type' => 'header', + 'name' => 'Authorization', + ], + 'query' => [ + 'type' => 'query', + 'name' => 'key', + ], + ]), + new PaginationOptions(true, 'page', true, 'itemsPerPage', true, 'pagination') + ); + + $dummySchema = new Schema('openapi'); + // $dummySchema = new Model\Schema(false, null, false, false, null, ['url' => 'http://schema.example.com/Dummy']); + $dummySchema->setDefinitions(new \ArrayObject([ + 'type' => 'object', + 'description' => 'This is a dummy.', + 'properties' => [ + 'id' => new \ArrayObject([ + 'type' => 'integer', + 'description' => 'This is an id.', + 'readOnly' => true, + 'minLength' => 3, + 'maxLength' => 20, + 'pattern' => '^dummyPattern$', + ]), + 'name' => new \ArrayObject([ + 'type' => 'string', + 'description' => 'This is a name.', + ]), + 'description' => new \ArrayObject([ + 'type' => 'string', + 'description' => 'This is an initializable but not writable property.', + ]), + 'dummy_date' => new \ArrayObject([ + 'type' => 'string', + 'description' => 'This is a \DateTimeInterface object.', + 'format' => 'date-time', + 'nullable' => true, + ]), + 'enum' => new \ArrayObject([ + 'type' => 'string', + 'enum' => ['one', 'two'], + 'example' => 'one', + 'description' => 'This is an enum.', + ]), + ], + 'externalDocs' => ['url' => 'http://schema.example.com/Dummy'], + ])); + + $openApi = $factory(['base_url' => '/app_dev.php/']); + + $this->assertInstanceOf(OpenApi::class, $openApi); + $this->assertEquals($openApi->getInfo(), new Model\Info('Test API', '1.2.3', 'This is a test API.')); + $this->assertEquals($openApi->getServers(), [new Model\Server('/app_dev.php/')]); + + $components = $openApi->getComponents(); + $this->assertInstanceOf(Model\Components::class, $components); + + $this->assertEquals($components->getSchemas(), new \ArrayObject(['Dummy' => $dummySchema->getDefinitions()])); + + $this->assertEquals($components->getSecuritySchemes(), new \ArrayObject([ + 'oauth' => new Model\SecurityScheme('oauth2', 'OAuth 2.0 authorization code Grant', null, null, 'oauth2', null, new Model\OAuthFlows(null, null, null, new Model\OAuthFlow('/oauth/v2/auth', '/oauth/v2/token', '/oauth/v2/refresh', new \ArrayObject(['scope param'])))), + 'header' => new Model\SecurityScheme('apiKey', 'Value for the Authorization header parameter.', 'Authorization', 'header', 'bearer'), + 'query' => new Model\SecurityScheme('apiKey', 'Value for the key query parameter.', 'key', 'query', 'bearer'), + ])); + + $paths = $openApi->getPaths(); + $dummiesPath = $paths->getPath('/dummies'); + $this->assertNotNull($dummiesPath); + foreach (['Put', 'Head', 'Trace', 'Delete', 'Options', 'Patch'] as $method) { + $this->assertNull($dummiesPath->{'get'.$method}()); + } + + $this->assertEquals($dummiesPath->getGet(), new Model\Operation( + 'getDummyCollection', + ['Dummy'], + [ + '200' => new Model\Response('Dummy collection', new \ArrayObject([ + 'application/ld+json' => new Model\MediaType(new \ArrayObject(new \ArrayObject([ + 'type' => 'array', + 'items' => ['$ref' => '#/components/schemas/Dummy'], + ]))), + ])), + ], + '', + 'Retrieves the collection of Dummy resources.', + null, + [ + new Model\Parameter('page', 'query', 'The collection page number', false, false, true, [ + 'type' => 'integer', + 'default' => 1, + ]), + new Model\Parameter('itemsPerPage', 'query', 'The number of items per page', false, false, true, [ + 'type' => 'integer', + 'default' => 30, + 'minimum' => 0, + ]), + new Model\Parameter('pagination', 'query', 'Enable or disable pagination', false, false, true, [ + 'type' => 'boolean', + ]), + ] + )); + + $this->assertEquals($dummiesPath->getPost(), new Model\Operation( + 'postDummyCollection', + ['Dummy'], + [ + '201' => new Model\Response( + 'Dummy resource created', + new \ArrayObject([ + 'application/ld+json' => new Model\MediaType(new \ArrayObject(new \ArrayObject(['$ref' => '#/components/schemas/Dummy']))), + ]), + null, + new \ArrayObject(['GetDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), [], 'The `id` value returned in the response can be used as the `id` parameter in `GET /dummies/{id}`.')]) + ), + '400' => new Model\Response('Invalid input'), + ], + '', + 'Creates a Dummy resource.', + null, + [], + new Model\RequestBody( + 'The new Dummy resource', + new \ArrayObject([ + 'application/ld+json' => new Model\MediaType(new \ArrayObject(new \ArrayObject(['$ref' => '#/components/schemas/Dummy']))), + ]), + true + ) + )); + + $dummyPath = $paths->getPath('/dummies/{id}'); + $this->assertNotNull($dummyPath); + foreach (['Post', 'Head', 'Trace', 'Options', 'Patch'] as $method) { + $this->assertNull($dummyPath->{'get'.$method}()); + } + + $this->assertEquals($dummyPath->getGet(), new Model\Operation( + 'getDummyItem', + ['Dummy'], + [ + '200' => new Model\Response( + 'Dummy resource', + new \ArrayObject([ + 'application/ld+json' => new Model\MediaType(new \ArrayObject(new \ArrayObject(['$ref' => '#/components/schemas/Dummy']))), + ]) + ), + '404' => new Model\Response('Resource not found'), + ], + '', + 'Retrieves a Dummy resource.', + null, + [new Model\Parameter('id', 'path', 'Resource identifier', true, false, false, ['type' => 'string'])] + )); + + $this->assertEquals($dummyPath->getPut(), new Model\Operation( + 'putDummyItem', + ['Dummy'], + [ + '200' => new Model\Response( + 'Dummy resource updated', + new \ArrayObject([ + 'application/ld+json' => new Model\MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy'])), + ]), + null, + new \ArrayObject(['GetDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), [], 'The `id` value returned in the response can be used as the `id` parameter in `GET /dummies/{id}`.')]) + ), + '400' => new Model\Response('Invalid input'), + '404' => new Model\Response('Resource not found'), + ], + '', + 'Replaces the Dummy resource.', + null, + [new Model\Parameter('id', 'path', 'Resource identifier', true, false, false, ['type' => 'string'])], + new Model\RequestBody( + 'The updated Dummy resource', + new \ArrayObject([ + 'application/ld+json' => new Model\MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy'])), + ]), + true + ) + )); + + $this->assertEquals($dummyPath->getDelete(), new Model\Operation( + 'deleteDummyItem', + ['Dummy'], + [ + '204' => new Model\Response('Dummy resource deleted'), + '404' => new Model\Response('Resource not found'), + ], + '', + 'Removes the Dummy resource.', + null, + [new Model\Parameter('id', 'path', 'Resource identifier', true, false, false, ['type' => 'string'])] + )); + + $customPath = $paths->getPath('/foo/{id}'); + $this->assertEquals($customPath->getHead(), new Model\Operation( + 'customDummyItem', + ['Dummy'], + [ + '404' => new Model\Response('Resource not found'), + ], + '', + 'Custom description', + null, + [new Model\Parameter('id', 'path', 'Resource identifier', true, false, false, ['type' => 'string'])] + )); + + $formattedPath = $paths->getPath('/formatted/{id}'); + $this->assertEquals($formattedPath->getPut(), new Model\Operation( + 'formatsDummyItem', + ['Dummy'], + [ + '200' => new Model\Response( + 'Dummy resource updated', + new \ArrayObject([ + 'application/json' => new Model\MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy'])), + 'text/csv' => new Model\MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy'])), + ]), + null, + new \ArrayObject(['GetDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), [], 'The `id` value returned in the response can be used as the `id` parameter in `GET /dummies/{id}`.')]) + ), + '400' => new Model\Response('Invalid input'), + '404' => new Model\Response('Resource not found'), + ], + '', + 'Replaces the Dummy resource.', + null, + [new Model\Parameter('id', 'path', 'Resource identifier', true, false, false, ['type' => 'string'])], + new Model\RequestBody( + 'The updated Dummy resource', + new \ArrayObject([ + 'application/json' => new Model\MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy'])), + 'text/csv' => new Model\MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy'])), + ]), + true + ) + )); + + $filteredPath = $paths->getPath('/filtered'); + $this->assertEquals($filteredPath->getGet(), new Model\Operation( + 'filteredDummyCollection', + ['Dummy'], + [ + '200' => new Model\Response('Dummy collection', new \ArrayObject([ + 'application/ld+json' => new Model\MediaType(new \ArrayObject([ + 'type' => 'array', + 'items' => ['$ref' => '#/components/schemas/Dummy'], + ])), + ])), + ], + '', + 'Retrieves the collection of Dummy resources.', + null, + [ + new Model\Parameter('page', 'query', 'The collection page number', false, false, true, [ + 'type' => 'integer', + 'default' => 1, + ]), + new Model\Parameter('itemsPerPage', 'query', 'The number of items per page', false, false, true, [ + 'type' => 'integer', + 'default' => 30, + 'minimum' => 0, + ]), + new Model\Parameter('pagination', 'query', 'Enable or disable pagination', false, false, true, [ + 'type' => 'boolean', + ]), + new Model\Parameter('name', 'query', '', true, true, true, [ + 'type' => 'string', + ], 'form', false, true, 'bar'), + new Model\Parameter('ha', 'query', '', false, false, true, [ + 'type' => 'integer', + ]), + new Model\Parameter('toto', 'query', '', true, false, true, [ + 'type' => 'array', + 'items' => ['type' => 'string'], + ], 'deepObject', true), + new Model\Parameter('order[name]', 'query', '', false, false, true, [ + 'type' => 'string', + ]), + ] + )); + + $paginatedPath = $paths->getPath('/paginated'); + $this->assertEquals($paginatedPath->getGet(), new Model\Operation( + 'paginatedDummyCollection', + ['Dummy'], + [ + '200' => new Model\Response('Dummy collection', new \ArrayObject([ + 'application/ld+json' => new Model\MediaType(new \ArrayObject([ + 'type' => 'array', + 'items' => ['$ref' => '#/components/schemas/Dummy'], + ])), + ])), + ], + '', + 'Retrieves the collection of Dummy resources.', + null, + [ + new Model\Parameter('page', 'query', 'The collection page number', false, false, true, [ + 'type' => 'integer', + 'default' => 1, + ]), + new Model\Parameter('itemsPerPage', 'query', 'The number of items per page', false, false, true, [ + 'type' => 'integer', + 'default' => 20, + 'minimum' => 0, + 'maximum' => 80, + ]), + new Model\Parameter('pagination', 'query', 'Enable or disable pagination', false, false, true, [ + 'type' => 'boolean', + ]), + ] + )); + } + + public function testOverrideDocumentation() + { + $defaultContext = ['base_url' => '/app_dev.php/']; + + $dummyMetadata = new ResourceMetadata( + 'Dummy', + 'This is a dummy.', + 'http://schema.example.com/Dummy', + [ + 'get' => ['method' => 'GET'] + self::OPERATION_FORMATS, + 'put' => ['method' => 'PUT'] + self::OPERATION_FORMATS, + 'delete' => ['method' => 'DELETE'] + self::OPERATION_FORMATS, + ], + [ + 'get' => ['method' => 'GET'] + self::OPERATION_FORMATS, + 'post' => ['method' => 'POST'] + self::OPERATION_FORMATS, + ], + [] + ); + + $subresourceOperationFactoryProphecy = $this->prophesize(SubresourceOperationFactoryInterface::class); + $subresourceOperationFactoryProphecy->create(Argument::any())->willReturn([]); + + $resourceNameCollectionFactoryProphecy = $this->prophesize(ResourceNameCollectionFactoryInterface::class); + $resourceNameCollectionFactoryProphecy->create()->shouldBeCalled()->willReturn(new ResourceNameCollection([Dummy::class])); + + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataFactoryProphecy->create(Dummy::class)->shouldBeCalled()->willReturn($dummyMetadata); + + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::any())->shouldBeCalled()->willReturn(new PropertyNameCollection(['id', 'name', 'description', 'dummyDate'])); + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'id', Argument::any())->shouldBeCalled()->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_INT), 'This is an id.', true, false, null, null, null, true, null, null, null, null, null, null, null, ['minLength' => 3, 'maxLength' => 20, 'pattern' => '^dummyPattern$'])); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::any())->shouldBeCalled()->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING), 'This is a name.', true, true, true, true, false, false, null, null, [], null, null, null, null)); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'description', Argument::any())->shouldBeCalled()->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING), 'This is an initializable but not writable property.', true, false, true, true, false, false, null, null, [], null, true)); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'dummyDate', Argument::any())->shouldBeCalled()->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_OBJECT, true, \DateTime::class), 'This is a \DateTimeInterface object.', true, true, true, true, false, false, null, null, [])); + + $operationPathResolver = new CustomOperationPathResolver(new OperationPathResolver(new UnderscorePathSegmentNameGenerator())); + $filterLocatorProphecy = $this->prophesize(ContainerInterface::class); + $resourceMetadataFactory = $resourceMetadataFactoryProphecy->reveal(); + $propertyNameCollectionFactory = $propertyNameCollectionFactoryProphecy->reveal(); + $propertyMetadataFactory = $propertyMetadataFactoryProphecy->reveal(); + + $typeFactory = new TypeFactory(); + $schemaFactory = new SchemaFactory($typeFactory, $resourceMetadataFactory, $propertyNameCollectionFactory, $propertyMetadataFactory, new CamelCaseToSnakeCaseNameConverter()); + $typeFactory->setSchemaFactory($schemaFactory); + + $factory = new OpenApiFactory( + $resourceNameCollectionFactoryProphecy->reveal(), + $resourceMetadataFactory, + $propertyNameCollectionFactory, + $propertyMetadataFactory, + $schemaFactory, + $typeFactory, + $operationPathResolver, + $filterLocatorProphecy->reveal(), + $subresourceOperationFactoryProphecy->reveal(), + [], + new Options('Test API', 'This is a test API.', '1.2.3', true, 'oauth2', 'authorizationCode', '/oauth/v2/token', '/oauth/v2/auth', '/oauth/v2/refresh', ['scope param'], [ + 'header' => [ + 'type' => 'header', + 'name' => 'Authorization', + ], + 'query' => [ + 'type' => 'query', + 'name' => 'key', + ], + ]), + new PaginationOptions(true, 'page', true, 'itemsPerPage', true, 'pagination') + ); + + $openApi = $factory(['base_url' => '/app_dev.php/']); + + $pathItem = $openApi->getPaths()->getPath('/dummies/{id}'); + $operation = $pathItem->getGet(); + + $openApi->getPaths()->addPath('/dummies/{id}', $pathItem->withGet( + $operation->withParameters(array_merge( + $operation->getParameters(), + [new Model\Parameter('fields', 'query', 'Fields to remove of the output')] + )) + )); + + $openApi = $openApi->withInfo((new Model\Info('New Title', 'v2', 'Description of my custom API'))->withExtensionProperty('info-key', 'Info value')); + $openApi = $openApi->withExtensionProperty('key', 'Custom x-key value'); + $openApi = $openApi->withExtensionProperty('x-value', 'Custom x-value value'); + + $this->assertEquals($openApi->getInfo()->getExtensionProperties(), ['x-info-key' => 'Info value']); + $this->assertEquals($openApi->getExtensionProperties(), ['x-key' => 'Custom x-key value', 'x-value' => 'Custom x-value value']); + } + + public function testSubresourceDocumentation() + { + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(Question::class, Argument::any())->shouldBeCalled()->willReturn(new PropertyNameCollection(['answer'])); + $propertyNameCollectionFactoryProphecy->create(Answer::class, Argument::any())->shouldBeCalled()->willReturn(new PropertyNameCollection(['content'])); + + $questionMetadata = new ResourceMetadata( + 'Question', + 'This is a question.', + 'http://schema.example.com/Question', + ['get' => ['method' => 'GET', 'input_formats' => ['json' => ['application/json'], 'csv' => ['text/csv']], 'output_formats' => ['json' => ['application/json'], 'csv' => ['text/csv']]]] + ); + $answerMetadata = new ResourceMetadata( + 'Answer', + 'This is an answer.', + 'http://schema.example.com/Answer', + [], + ['get' => ['method' => 'GET'] + self::OPERATION_FORMATS], + [], + ['get' => ['method' => 'GET', 'input_formats' => ['xml' => ['text/xml']], 'output_formats' => ['xml' => ['text/xml']]]] + ); + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataFactoryProphecy->create(Question::class)->shouldBeCalled()->willReturn($questionMetadata); + $resourceMetadataFactoryProphecy->create(Answer::class)->shouldBeCalled()->willReturn($answerMetadata); + + $subresourceMetadata = new SubresourceMetadata(Answer::class, false); + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Question::class, 'answer', Argument::any())->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, [], $subresourceMetadata)); + + $propertyMetadataFactoryProphecy->create(Answer::class, 'content', Argument::any())->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, [])); + + $routeCollection = new RouteCollection(); + $routeCollection->add('api_answers_get_collection', new Route('/api/answers.{_format}')); + $routeCollection->add('api_questions_answer_get_subresource', new Route('/api/questions/{id}/answer.{_format}')); + $routeCollection->add('api_questions_get_item', new Route('/api/questions/{id}.{_format}')); + + $routerProphecy = $this->prophesize(RouterInterface::class); + $routerProphecy->getRouteCollection()->shouldBeCalled()->willReturn($routeCollection); + + $operationPathResolver = new RouterOperationPathResolver($routerProphecy->reveal(), new CustomOperationPathResolver(new OperationPathResolver(new UnderscorePathSegmentNameGenerator()))); + + $resourceMetadataFactory = $resourceMetadataFactoryProphecy->reveal(); + $propertyNameCollectionFactory = $propertyNameCollectionFactoryProphecy->reveal(); + $propertyMetadataFactory = $propertyMetadataFactoryProphecy->reveal(); + + $subresourceOperationFactory = new SubresourceOperationFactory($resourceMetadataFactory, $propertyNameCollectionFactory, $propertyMetadataFactory, new UnderscorePathSegmentNameGenerator()); + + $resourceNameCollectionFactoryProphecy = $this->prophesize(ResourceNameCollectionFactoryInterface::class); + $resourceNameCollectionFactoryProphecy->create()->shouldBeCalled()->willReturn(new ResourceNameCollection([Question::class, Answer::class])); + + $typeFactory = new TypeFactory(); + $schemaFactory = new SchemaFactory($typeFactory, $resourceMetadataFactory, $propertyNameCollectionFactory, $propertyMetadataFactory, new CamelCaseToSnakeCaseNameConverter()); + $typeFactory->setSchemaFactory($schemaFactory); + $filterLocatorProphecy = $this->prophesize(ContainerInterface::class); + + $factory = new OpenApiFactory( + $resourceNameCollectionFactoryProphecy->reveal(), + $resourceMetadataFactory, + $propertyNameCollectionFactory, + $propertyMetadataFactory, + $schemaFactory, + $typeFactory, + $operationPathResolver, + $filterLocatorProphecy->reveal(), + $subresourceOperationFactory, + ['jsonld' => ['application/ld+json']], + new Options('Test API', 'This is a test API.', '1.2.3', true, 'oauth2', 'authorizationCode', '/oauth/v2/token', '/oauth/v2/auth', '/oauth/v2/refresh', ['scope param'], [ + 'header' => [ + 'type' => 'header', + 'name' => 'Authorization', + ], + 'query' => [ + 'type' => 'query', + 'name' => 'key', + ], + ]), + new PaginationOptions(true, 'page', true, 'itemsPerPage', true, 'pagination') + ); + + $openApi = $factory(['base_url', '/app_dev.php/']); + + $paths = $openApi->getPaths(); + $pathItem = $paths->getPath('/questions/{id}/answer.{_format}'); + + $this->assertEquals($pathItem->getGet(), new Model\Operation( + 'api_questions_answer_get_subresourceQuestionSubresource', + ['Answer', 'Question'], + [ + '200' => new Model\Response( + 'Question resource', + new \ArrayObject([ + 'application/ld+json' => new Model\MediaType(new \ArrayObject(new \ArrayObject(['$ref' => '#/components/schemas/Question']))), + ]) + ), + ], + '', + 'Retrieves a Question resource.', + null, + [new Model\Parameter('id', 'path', 'Question identifier', true, false, false, ['type' => 'string'])] + )); + } +} diff --git a/tests/OpenApi/Serializer/OpenApiNormalizerTest.php b/tests/OpenApi/Serializer/OpenApiNormalizerTest.php new file mode 100644 index 00000000000..30444db0e4c --- /dev/null +++ b/tests/OpenApi/Serializer/OpenApiNormalizerTest.php @@ -0,0 +1,158 @@ + + * + * 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\Tests\OpenApi\Serializer; + +use ApiPlatform\Core\DataProvider\PaginationOptions; +use ApiPlatform\Core\JsonSchema\SchemaFactory; +use ApiPlatform\Core\JsonSchema\TypeFactory; +use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; +use ApiPlatform\Core\Metadata\Property\PropertyMetadata; +use ApiPlatform\Core\Metadata\Property\PropertyNameCollection; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; +use ApiPlatform\Core\Metadata\Resource\ResourceNameCollection; +use ApiPlatform\Core\OpenApi\Factory\OpenApiFactory; +use ApiPlatform\Core\OpenApi\Model; +use ApiPlatform\Core\OpenApi\Options; +use ApiPlatform\Core\OpenApi\Serializer\OpenApiNormalizer; +use ApiPlatform\Core\Operation\Factory\SubresourceOperationFactoryInterface; +use ApiPlatform\Core\Operation\UnderscorePathSegmentNameGenerator; +use ApiPlatform\Core\PathResolver\CustomOperationPathResolver; +use ApiPlatform\Core\PathResolver\OperationPathResolver; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy; +use PHPUnit\Framework\TestCase; +use Prophecy\Argument; +use Psr\Container\ContainerInterface; +use Symfony\Component\PropertyInfo\Type; +use Symfony\Component\Serializer\Encoder\JsonEncoder; +use Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter; +use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; +use Symfony\Component\Serializer\Serializer; + +class OpenApiNormalizerTest extends TestCase +{ + private const OPERATION_FORMATS = [ + 'input_formats' => ['jsonld' => ['application/ld+json']], + 'output_formats' => ['jsonld' => ['application/ld+json']], + ]; + + public function testNormalize() + { + $resourceNameCollectionFactoryProphecy = $this->prophesize(ResourceNameCollectionFactoryInterface::class); + $resourceNameCollectionFactoryProphecy->create()->shouldBeCalled()->willReturn(new ResourceNameCollection([Dummy::class])); + $defaultContext = ['base_url' => '/app_dev.php/']; + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::any())->shouldBeCalled()->willReturn(new PropertyNameCollection(['id', 'name', 'description', 'dummyDate'])); + + $dummyMetadata = new ResourceMetadata( + 'Dummy', + 'This is a dummy.', + 'http://schema.example.com/Dummy', + [ + 'get' => ['method' => 'GET'] + self::OPERATION_FORMATS, + 'put' => ['method' => 'PUT'] + self::OPERATION_FORMATS, + 'delete' => ['method' => 'DELETE'] + self::OPERATION_FORMATS, + ], + [ + 'get' => ['method' => 'GET'] + self::OPERATION_FORMATS, + 'post' => ['method' => 'POST'] + self::OPERATION_FORMATS, + ], + [] + ); + + $subresourceOperationFactoryProphecy = $this->prophesize(SubresourceOperationFactoryInterface::class); + $subresourceOperationFactoryProphecy->create(Argument::any(), Argument::any(), Argument::any())->willReturn([]); + + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataFactoryProphecy->create(Dummy::class)->shouldBeCalled()->willReturn($dummyMetadata); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'id', Argument::any())->shouldBeCalled()->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_INT), 'This is an id.', true, false, null, null, null, true, null, null, null, null, null, null, null, ['minLength' => 3, 'maxLength' => 20, 'pattern' => '^dummyPattern$'])); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::any())->shouldBeCalled()->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING), 'This is a name.', true, true, true, true, false, false, null, null, [], null, null, null, null)); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'description', Argument::any())->shouldBeCalled()->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING), 'This is an initializable but not writable property.', true, false, true, true, false, false, null, null, [], null, true)); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'dummyDate', Argument::any())->shouldBeCalled()->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_OBJECT, true, \DateTime::class), 'This is a \DateTimeInterface object.', true, true, true, true, false, false, null, null, [])); + + $operationPathResolver = new CustomOperationPathResolver(new OperationPathResolver(new UnderscorePathSegmentNameGenerator())); + $filterLocatorProphecy = $this->prophesize(ContainerInterface::class); + $resourceMetadataFactory = $resourceMetadataFactoryProphecy->reveal(); + $propertyNameCollectionFactory = $propertyNameCollectionFactoryProphecy->reveal(); + $propertyMetadataFactory = $propertyMetadataFactoryProphecy->reveal(); + + $typeFactory = new TypeFactory(); + $schemaFactory = new SchemaFactory($typeFactory, $resourceMetadataFactory, $propertyNameCollectionFactory, $propertyMetadataFactory, new CamelCaseToSnakeCaseNameConverter()); + $typeFactory->setSchemaFactory($schemaFactory); + + $factory = new OpenApiFactory( + $resourceNameCollectionFactoryProphecy->reveal(), + $resourceMetadataFactory, + $propertyNameCollectionFactory, + $propertyMetadataFactory, + $schemaFactory, + $typeFactory, + $operationPathResolver, + $filterLocatorProphecy->reveal(), + $subresourceOperationFactoryProphecy->reveal(), + [], + new Options('Test API', 'This is a test API.', '1.2.3', true, 'oauth2', 'authorizationCode', '/oauth/v2/token', '/oauth/v2/auth', '/oauth/v2/refresh', ['scope param'], [ + 'header' => [ + 'type' => 'header', + 'name' => 'Authorization', + ], + 'query' => [ + 'type' => 'query', + 'name' => 'key', + ], + ]), + new PaginationOptions(true, 'page', true, 'itemsPerPage', true, 'pagination') + ); + + $openApi = $factory(['base_url' => '/app_dev.php/']); + + $pathItem = $openApi->getPaths()->getPath('/dummies/{id}'); + $operation = $pathItem->getGet(); + + $openApi->getPaths()->addPath('/dummies/{id}', $pathItem->withGet( + $operation->withParameters(array_merge( + $operation->getParameters(), + [new Model\Parameter('fields', 'query', 'Fields to remove of the output')] + )) + )); + + $openApi = $openApi->withInfo((new Model\Info('New Title', 'v2', 'Description of my custom API'))->withExtensionProperty('info-key', 'Info value')); + $openApi = $openApi->withExtensionProperty('key', 'Custom x-key value'); + $openApi = $openApi->withExtensionProperty('x-value', 'Custom x-value value'); + + $encoders = [new JsonEncoder()]; + $normalizers = [new ObjectNormalizer()]; + + $serializer = new Serializer($normalizers, $encoders); + $normalizers[0]->setSerializer($serializer); + + $normalizer = new OpenApiNormalizer($normalizers[0]); + + $openApiAsArray = $normalizer->normalize($openApi); + + // Just testing normalization specifics + $this->assertEquals($openApiAsArray['x-key'], 'Custom x-key value'); + $this->assertEquals($openApiAsArray['x-value'], 'Custom x-value value'); + $this->assertEquals($openApiAsArray['info']['x-info-key'], 'Info value'); + $this->assertArrayNotHasKey('extensionProperties', $openApiAsArray); + // this key is null, should not be in the output + $this->assertArrayNotHasKey('termsOfService', $openApiAsArray['info']); + $this->assertArrayNotHasKey('paths', $openApiAsArray['paths']); + $this->assertArrayHasKey('/dummies/{id}', $openApiAsArray['paths']); + } +} From f6fd3b371abde8026dd874cf84ed3ab05ce245bb Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Mon, 27 Jul 2020 14:40:37 +0200 Subject: [PATCH 104/160] fix: use new OpenApi with SwaggerUI (#3657) --- features/openapi/docs.feature | 2 - .../Symfony/Bundle/Action/SwaggerUiAction.php | 15 +- .../Bundle/Resources/config/swagger-ui.xml | 24 ++ .../Resources/views/SwaggerUi/index.html.twig | 1 + .../Bundle/SwaggerUi/SwaggerUiAction.php | 123 ++++++++++ .../Bundle/SwaggerUi/SwaggerUiContext.php | 64 ++++++ .../Bundle/Action/SwaggerUiActionTest.php | 7 + .../ApiPlatformExtensionTest.php | 3 + .../Bundle/SwaggerUi/SwaggerUiActionTest.php | 214 ++++++++++++++++++ 9 files changed, 450 insertions(+), 3 deletions(-) create mode 100644 src/Bridge/Symfony/Bundle/SwaggerUi/SwaggerUiAction.php create mode 100644 src/Bridge/Symfony/Bundle/SwaggerUi/SwaggerUiContext.php create mode 100644 tests/Bridge/Symfony/Bundle/SwaggerUi/SwaggerUiActionTest.php diff --git a/features/openapi/docs.feature b/features/openapi/docs.feature index ec8806eaafe..e3c27d0a220 100644 --- a/features/openapi/docs.feature +++ b/features/openapi/docs.feature @@ -112,11 +112,9 @@ Feature: Documentation support Then the response status code should be 200 And I should see text matching "My Dummy API" And I should see text matching "openapi" - And I should see text matching "3.0.2" Scenario: OpenAPI UI is enabled for an arbitrary endpoint Given I add "Accept" header equal to "text/html" And I send a "GET" request to "/dummies?spec_version=3" Then the response status code should be 200 And I should see text matching "openapi" - And I should see text matching "3.0.2" diff --git a/src/Bridge/Symfony/Bundle/Action/SwaggerUiAction.php b/src/Bridge/Symfony/Bundle/Action/SwaggerUiAction.php index 83bebc0e64e..654736f229c 100644 --- a/src/Bridge/Symfony/Bundle/Action/SwaggerUiAction.php +++ b/src/Bridge/Symfony/Bundle/Action/SwaggerUiAction.php @@ -14,6 +14,7 @@ namespace ApiPlatform\Core\Bridge\Symfony\Bundle\Action; use ApiPlatform\Core\Api\FormatsProviderInterface; +use ApiPlatform\Core\Bridge\Symfony\Bundle\SwaggerUi\SwaggerUiAction as OpenApiSwaggerUiAction; use ApiPlatform\Core\Documentation\Documentation; use ApiPlatform\Core\Exception\RuntimeException; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; @@ -28,6 +29,8 @@ /** * Displays the documentation. * + * @deprecated please refer to ApiPlatform\Core\Bridge\Symfony\Bundle\SwaggerUi\SwaggerUiAction for further changes + * * @author Kévin Dunglas */ final class SwaggerUiAction @@ -57,11 +60,12 @@ final class SwaggerUiAction private $graphiQlEnabled; private $graphQlPlaygroundEnabled; private $swaggerVersions; + private $swaggerUiAction; /** * @param int[] $swaggerVersions */ - public function __construct(ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, ResourceMetadataFactoryInterface $resourceMetadataFactory, NormalizerInterface $normalizer, TwigEnvironment $twig, UrlGeneratorInterface $urlGenerator, string $title = '', string $description = '', string $version = '', $formats = [], $oauthEnabled = false, $oauthClientId = '', $oauthClientSecret = '', $oauthType = '', $oauthFlow = '', $oauthTokenUrl = '', $oauthAuthorizationUrl = '', $oauthScopes = [], bool $showWebby = true, bool $swaggerUiEnabled = false, bool $reDocEnabled = false, bool $graphqlEnabled = false, bool $graphiQlEnabled = false, bool $graphQlPlaygroundEnabled = false, array $swaggerVersions = [2, 3]) + public function __construct(ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, ResourceMetadataFactoryInterface $resourceMetadataFactory, NormalizerInterface $normalizer, TwigEnvironment $twig, UrlGeneratorInterface $urlGenerator, string $title = '', string $description = '', string $version = '', $formats = [], $oauthEnabled = false, $oauthClientId = '', $oauthClientSecret = '', $oauthType = '', $oauthFlow = '', $oauthTokenUrl = '', $oauthAuthorizationUrl = '', $oauthScopes = [], bool $showWebby = true, bool $swaggerUiEnabled = false, bool $reDocEnabled = false, bool $graphqlEnabled = false, bool $graphiQlEnabled = false, bool $graphQlPlaygroundEnabled = false, array $swaggerVersions = [2, 3], OpenApiSwaggerUiAction $swaggerUiAction = null) { $this->resourceNameCollectionFactory = $resourceNameCollectionFactory; $this->resourceMetadataFactory = $resourceMetadataFactory; @@ -86,6 +90,11 @@ public function __construct(ResourceNameCollectionFactoryInterface $resourceName $this->graphiQlEnabled = $graphiQlEnabled; $this->graphQlPlaygroundEnabled = $graphQlPlaygroundEnabled; $this->swaggerVersions = $swaggerVersions; + $this->swaggerUiAction = $swaggerUiAction; + + if (null === $this->swaggerUiAction) { + @trigger_error(sprintf('The use of "%s" is deprecated since API Platform 2.6, use "%s" instead.', __CLASS__, OpenApiSwaggerUiAction::class), E_USER_DEPRECATED); + } if (\is_array($formats)) { $this->formats = $formats; @@ -99,6 +108,10 @@ public function __construct(ResourceNameCollectionFactoryInterface $resourceName public function __invoke(Request $request) { + if ($this->swaggerUiAction) { + return $this->swaggerUiAction->__invoke($request); + } + $attributes = RequestAttributesExtractor::extractAttributes($request); // BC check to be removed in 3.0 diff --git a/src/Bridge/Symfony/Bundle/Resources/config/swagger-ui.xml b/src/Bridge/Symfony/Bundle/Resources/config/swagger-ui.xml index 2bcb783853c..2b7b80bd8f1 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/swagger-ui.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/swagger-ui.xml @@ -9,6 +9,7 @@ + @@ -35,6 +36,29 @@ %api_platform.graphql.graphiql.enabled% %api_platform.graphql.graphql_playground.enabled% %api_platform.swagger.versions% + + + + + %api_platform.enable_swagger_ui% + %api_platform.show_webby% + %api_platform.enable_re_doc% + %api_platform.graphql.enabled% + %api_platform.graphql.graphiql.enabled% + %api_platform.graphql.graphql_playground.enabled% + + + + + + + + + + + %api_platform.formats% + %api_platform.oauth.clientId% + %api_platform.oauth.clientSecret% diff --git a/src/Bridge/Symfony/Bundle/Resources/views/SwaggerUi/index.html.twig b/src/Bridge/Symfony/Bundle/Resources/views/SwaggerUi/index.html.twig index 620c58ce7ee..88547ad5e0c 100644 --- a/src/Bridge/Symfony/Bundle/Resources/views/SwaggerUi/index.html.twig +++ b/src/Bridge/Symfony/Bundle/Resources/views/SwaggerUi/index.html.twig @@ -72,6 +72,7 @@ {% set active_ui = app.request.get('ui', 'swagger_ui') %} {% if swaggerUiEnabled and active_ui != 'swagger_ui' %}Swagger UI{% endif %} {% if reDocEnabled and active_ui != 're_doc' %}ReDoc{% endif %} + {# FIXME: Typo in graphql => graphQl in SwaggerUiAction #} {% if not graphqlEnabled %}GraphiQL{% endif %} {% if graphiQlEnabled %}GraphiQL{% endif %} {% if graphQlPlaygroundEnabled %}GraphQL Playground{% endif %} diff --git a/src/Bridge/Symfony/Bundle/SwaggerUi/SwaggerUiAction.php b/src/Bridge/Symfony/Bundle/SwaggerUi/SwaggerUiAction.php new file mode 100644 index 00000000000..2c873c655cc --- /dev/null +++ b/src/Bridge/Symfony/Bundle/SwaggerUi/SwaggerUiAction.php @@ -0,0 +1,123 @@ + + * + * 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\Bridge\Symfony\Bundle\SwaggerUi; + +use ApiPlatform\Core\Exception\RuntimeException; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\OpenApi\Factory\OpenApiFactoryInterface; +use ApiPlatform\Core\OpenApi\Options; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Routing\Generator\UrlGeneratorInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; +use Twig\Environment as TwigEnvironment; + +/** + * Displays the swaggerui interface. + * + * @author Antoine Bluchet + */ +final class SwaggerUiAction +{ + private $twig; + private $urlGenerator; + private $normalizer; + private $openApiFactory; + private $openApiOptions; + private $swaggerUiContext; + private $formats; + private $resourceMetadataFactory; + private $oauthClientId; + private $oauthClientSecret; + + public function __construct(ResourceMetadataFactoryInterface $resourceMetadataFactory, TwigEnvironment $twig, UrlGeneratorInterface $urlGenerator, NormalizerInterface $normalizer, OpenApiFactoryInterface $openApiFactory, Options $openApiOptions, SwaggerUiContext $swaggerUiContext, array $formats = [], string $oauthClientId = null, string $oauthClientSecret = null) + { + $this->resourceMetadataFactory = $resourceMetadataFactory; + $this->twig = $twig; + $this->urlGenerator = $urlGenerator; + $this->normalizer = $normalizer; + $this->openApiFactory = $openApiFactory; + $this->openApiOptions = $openApiOptions; + $this->swaggerUiContext = $swaggerUiContext; + $this->formats = $formats; + $this->oauthClientId = $oauthClientId; + $this->oauthClientSecret = $oauthClientSecret; + } + + public function __invoke(Request $request) + { + $openApi = $this->openApiFactory->__invoke(['base_url' => $request->getBaseUrl() ?: '/']); + + $swaggerContext = [ + 'formats' => $this->formats, + 'title' => $openApi->getInfo()->getTitle(), + 'description' => $openApi->getInfo()->getDescription(), + 'showWebby' => $this->swaggerUiContext->isWebbyShown(), + 'swaggerUiEnabled' => $this->swaggerUiContext->isSwaggerUiEnabled(), + 'reDocEnabled' => $this->swaggerUiContext->isRedocEnabled(), + // FIXME: typo graphql => graphQl + 'graphqlEnabled' => $this->swaggerUiContext->isGraphQlEnabled(), + 'graphiQlEnabled' => $this->swaggerUiContext->isGraphiQlEnabled(), + 'graphQlPlaygroundEnabled' => $this->swaggerUiContext->isGraphQlPlaygroundEnabled(), + ]; + + $swaggerData = [ + 'url' => $this->urlGenerator->generate('api_doc', ['format' => 'json']), + 'spec' => $this->normalizer->normalize($openApi, 'json', []), + 'oauth' => [ + 'enabled' => $this->openApiOptions->getOAuthEnabled(), + 'type' => $this->openApiOptions->getOAuthType(), + 'flow' => $this->openApiOptions->getOAuthFlow(), + 'tokenUrl' => $this->openApiOptions->getOAuthTokenUrl(), + 'authorizationUrl' => $this->openApiOptions->getOAuthAuthorizationUrl(), + 'scopes' => $this->openApiOptions->getOAuthScopes(), + 'clientId' => $this->oauthClientId, + 'clientSecret' => $this->oauthClientSecret, + ], + ]; + + if ($request->isMethodSafe() && null !== $resourceClass = $request->attributes->get('_api_resource_class')) { + $swaggerData['id'] = $request->attributes->get('id'); + $swaggerData['queryParameters'] = $request->query->all(); + + $metadata = $this->resourceMetadataFactory->create($resourceClass); + $swaggerData['shortName'] = $metadata->getShortName(); + + if (null !== $collectionOperationName = $request->attributes->get('_api_collection_operation_name')) { + $swaggerData['operationId'] = sprintf('%s%sCollection', $collectionOperationName, ucfirst($swaggerData['shortName'])); + } elseif (null !== $itemOperationName = $request->attributes->get('_api_item_operation_name')) { + $swaggerData['operationId'] = sprintf('%s%sItem', $itemOperationName, ucfirst($swaggerData['shortName'])); + } elseif (null !== $subresourceOperationContext = $request->attributes->get('_api_subresource_context')) { + $swaggerData['operationId'] = $subresourceOperationContext['operationId']; + } + + [$swaggerData['path'], $swaggerData['method']] = $this->getPathAndMethod($swaggerData); + } + + return new Response($this->twig->render('@ApiPlatform/SwaggerUi/index.html.twig', $swaggerContext + ['swagger_data' => $swaggerData])); + } + + private function getPathAndMethod(array $swaggerData): array + { + foreach ($swaggerData['spec']['paths'] as $path => $operations) { + foreach ($operations as $method => $operation) { + if ($operation['operationId'] === $swaggerData['operationId']) { + return [$path, $method]; + } + } + } + + throw new RuntimeException(sprintf('The operation "%s" cannot be found in the Swagger specification.', $swaggerData['operationId'])); + } +} diff --git a/src/Bridge/Symfony/Bundle/SwaggerUi/SwaggerUiContext.php b/src/Bridge/Symfony/Bundle/SwaggerUi/SwaggerUiContext.php new file mode 100644 index 00000000000..9586ad3287f --- /dev/null +++ b/src/Bridge/Symfony/Bundle/SwaggerUi/SwaggerUiContext.php @@ -0,0 +1,64 @@ + + * + * 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\Bridge\Symfony\Bundle\SwaggerUi; + +final class SwaggerUiContext +{ + private $swaggerUiEnabled; + private $showWebby; + private $reDocEnabled; + private $graphQlEnabled; + private $graphiQlEnabled; + private $graphQlPlaygroundEnabled; + + public function __construct(bool $swaggerUiEnabled = false, bool $showWebby = true, bool $reDocEnabled = false, bool $graphQlEnabled = false, bool $graphiQlEnabled = false, bool $graphQlPlaygroundEnabled = false) + { + $this->swaggerUiEnabled = $swaggerUiEnabled; + $this->showWebby = $showWebby; + $this->reDocEnabled = $reDocEnabled; + $this->graphQlEnabled = $graphQlEnabled; + $this->graphiQlEnabled = $graphiQlEnabled; + $this->graphQlPlaygroundEnabled = $graphQlPlaygroundEnabled; + } + + public function isSwaggerUiEnabled(): bool + { + return $this->swaggerUiEnabled; + } + + public function isWebbyShown(): bool + { + return $this->showWebby; + } + + public function isRedocEnabled(): bool + { + return $this->reDocEnabled; + } + + public function isGraphQlEnabled(): bool + { + return $this->graphQlEnabled; + } + + public function isGraphiQlEnabled(): bool + { + return $this->graphiQlEnabled; + } + + public function isGraphQlPlaygroundEnabled(): bool + { + return $this->graphQlPlaygroundEnabled; + } +} diff --git a/tests/Bridge/Symfony/Bundle/Action/SwaggerUiActionTest.php b/tests/Bridge/Symfony/Bundle/Action/SwaggerUiActionTest.php index 513e09b473f..b0353b04916 100644 --- a/tests/Bridge/Symfony/Bundle/Action/SwaggerUiActionTest.php +++ b/tests/Bridge/Symfony/Bundle/Action/SwaggerUiActionTest.php @@ -21,6 +21,7 @@ use ApiPlatform\Core\Metadata\Resource\ResourceNameCollection; use PHPUnit\Framework\TestCase; use Prophecy\Argument; +use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Routing\Generator\UrlGenerator; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; @@ -31,6 +32,8 @@ */ class SwaggerUiActionTest extends TestCase { + use ExpectDeprecationTrait; + public const SPEC = [ 'paths' => [ '/fs' => ['get' => ['operationId' => 'getFCollection']], @@ -40,9 +43,11 @@ class SwaggerUiActionTest extends TestCase /** * @dataProvider getInvokeParameters + * @group legacy */ public function testInvoke(Request $request, $twigProphecy) { + $this->expectDeprecation('The use of "ApiPlatform\Core\Bridge\Symfony\Bundle\Action\SwaggerUiAction" is deprecated since API Platform 2.6, use "ApiPlatform\Core\Bridge\Symfony\Bundle\SwaggerUi\SwaggerUiAction" instead.'); $resourceNameCollectionFactoryProphecy = $this->prophesize(ResourceNameCollectionFactoryInterface::class); $resourceNameCollectionFactoryProphecy->create()->willReturn(new ResourceNameCollection(['Foo', 'Bar']))->shouldBeCalled(); @@ -142,9 +147,11 @@ public function getInvokeParameters() /** * @dataProvider getDoNotRunCurrentRequestParameters + * @group legacy */ public function testDoNotRunCurrentRequest(Request $request) { + $this->expectDeprecation('The use of "ApiPlatform\Core\Bridge\Symfony\Bundle\Action\SwaggerUiAction" is deprecated since API Platform 2.6, use "ApiPlatform\Core\Bridge\Symfony\Bundle\SwaggerUi\SwaggerUiAction" instead.'); $resourceNameCollectionFactoryProphecy = $this->prophesize(ResourceNameCollectionFactoryInterface::class); $resourceNameCollectionFactoryProphecy->create()->willReturn(new ResourceNameCollection(['Foo', 'Bar']))->shouldBeCalled(); diff --git a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php index 2271044208c..ea4a9e9b8cf 100644 --- a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php +++ b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php @@ -1331,6 +1331,8 @@ private function getBaseContainerBuilderProphecy(array $doctrineIntegrationsToLo $definitions[] = 'api_platform.openapi.normalizer.api_gateway'; $definitions[] = 'api_platform.openapi.factory'; $definitions[] = 'api_platform.openapi.command'; + $definitions[] = 'api_platform.swagger_ui.context'; + $definitions[] = 'api_platform.swagger_ui.action'; } // has jsonld @@ -1405,6 +1407,7 @@ private function getBaseContainerBuilderProphecy(array $doctrineIntegrationsToLo Options::class => 'api_platform.openapi.options', OpenApiNormalizer::class => 'api_platform.openapi.normalizer', OpenApiFactoryInterface::class => 'api_platform.openapi.factory', + 'api_platform.swagger_ui.listener' => 'api_platform.swagger.listener.ui', ]; } diff --git a/tests/Bridge/Symfony/Bundle/SwaggerUi/SwaggerUiActionTest.php b/tests/Bridge/Symfony/Bundle/SwaggerUi/SwaggerUiActionTest.php new file mode 100644 index 00000000000..1a231b883c7 --- /dev/null +++ b/tests/Bridge/Symfony/Bundle/SwaggerUi/SwaggerUiActionTest.php @@ -0,0 +1,214 @@ + + * + * 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\Tests\Bridge\Symfony\Bundle\SwaggerUi; + +use ApiPlatform\Core\Bridge\Symfony\Bundle\SwaggerUi\SwaggerUiAction; +use ApiPlatform\Core\Bridge\Symfony\Bundle\SwaggerUi\SwaggerUiContext; +use ApiPlatform\Core\Documentation\DocumentationInterface; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; +use ApiPlatform\Core\OpenApi\Factory\OpenApiFactoryInterface; +use ApiPlatform\Core\OpenApi\Model\Info; +use ApiPlatform\Core\OpenApi\Model\Paths; +use ApiPlatform\Core\OpenApi\OpenApi; +use ApiPlatform\Core\OpenApi\Options; +use PHPUnit\Framework\TestCase; +use Prophecy\Argument; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\Routing\Generator\UrlGenerator; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; +use Twig\Environment as TwigEnvironment; + +/** + * @author Antoine Bluchet + */ +class SwaggerUiActionTest extends TestCase +{ + public const SPEC = [ + 'paths' => [ + '/fs' => ['get' => ['operationId' => 'getFCollection']], + '/fs/{id}' => ['get' => ['operationId' => 'getFItem']], + ], + ]; + + /** + * @dataProvider getInvokeParameters + */ + public function testInvoke(Request $request, $twigProphecy) + { + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataFactoryProphecy->create('Foo')->willReturn(new ResourceMetadata('F'))->shouldBeCalled(); + + $normalizerProphecy = $this->prophesize(NormalizerInterface::class); + $normalizerProphecy->normalize(Argument::type(DocumentationInterface::class), 'json', Argument::type('array'))->willReturn(self::SPEC)->shouldBeCalled(); + + $urlGeneratorProphecy = $this->prophesize(UrlGenerator::class); + $urlGeneratorProphecy->generate('api_doc', ['format' => 'json'])->willReturn('/url')->shouldBeCalled(); + + $openApiFactoryProphecy = $this->prophesize(OpenApiFactoryInterface::class); + $openApiFactoryProphecy->__invoke(Argument::type('array'))->willReturn(new OpenApi(new Info('title', '1.0.0'), [], new Paths()))->shouldBeCalled(); + + $action = new SwaggerUiAction( + $resourceMetadataFactoryProphecy->reveal(), + $twigProphecy->reveal(), + $urlGeneratorProphecy->reveal(), + $normalizerProphecy->reveal(), + $openApiFactoryProphecy->reveal(), + new Options('title', '', '1.0.0'), + new SwaggerUiContext(), + ['jsonld' => ['application/ld+json']] + ); + $action($request); + } + + public function getInvokeParameters() + { + $twigCollectionProphecy = $this->prophesize(TwigEnvironment::class); + $twigCollectionProphecy->render('@ApiPlatform/SwaggerUi/index.html.twig', [ + 'title' => '', + 'description' => '', + 'formats' => [], + 'showWebby' => true, + 'swaggerUiEnabled' => false, + 'reDocEnabled' => false, + 'graphqlEnabled' => false, + 'graphiQlEnabled' => false, + 'graphQlPlaygroundEnabled' => false, + 'swagger_data' => [ + 'url' => '/url', + 'spec' => self::SPEC, + 'oauth' => [ + 'enabled' => false, + 'clientId' => '', + 'clientSecret' => '', + 'type' => '', + 'flow' => '', + 'tokenUrl' => '', + 'authorizationUrl' => '', + 'scopes' => [], + ], + 'shortName' => 'F', + 'operationId' => 'getFCollection', + 'id' => null, + 'queryParameters' => [], + 'path' => '/fs', + 'method' => 'get', + ], + ])->shouldBeCalled(); + + $twigItemProphecy = $this->prophesize(TwigEnvironment::class); + $twigItemProphecy->render('@ApiPlatform/SwaggerUi/index.html.twig', [ + 'title' => '', + 'description' => '', + 'formats' => [], + 'swaggerUiEnabled' => false, + 'showWebby' => true, + 'reDocEnabled' => false, + 'graphqlEnabled' => false, + 'graphiQlEnabled' => false, + 'graphQlPlaygroundEnabled' => false, + 'swagger_data' => [ + 'url' => '/url', + 'spec' => self::SPEC, + 'oauth' => [ + 'enabled' => false, + 'clientId' => '', + 'clientSecret' => '', + 'type' => '', + 'flow' => '', + 'tokenUrl' => '', + 'authorizationUrl' => '', + 'scopes' => [], + ], + 'shortName' => 'F', + 'operationId' => 'getFItem', + 'id' => null, + 'queryParameters' => [], + 'path' => '/fs/{id}', + 'method' => 'get', + ], + ])->shouldBeCalled(); + + return [ + [new Request([], [], ['_api_resource_class' => 'Foo', '_api_collection_operation_name' => 'get']), $twigCollectionProphecy], + [new Request([], [], ['_api_resource_class' => 'Foo', '_api_item_operation_name' => 'get']), $twigItemProphecy], + [new Request([], [], ['_api_resource_class' => 'Foo', '_api_item_operation_name' => 'get'], [], [], ['REQUEST_URI' => '/docs', 'SCRIPT_FILENAME' => '/docs']), $twigItemProphecy], + ]; + } + + /** + * @dataProvider getDoNotRunCurrentRequestParameters + */ + public function testDoNotRunCurrentRequest(Request $request) + { + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataFactoryProphecy->create('Foo')->willReturn(new ResourceMetadata()); + + $normalizerProphecy = $this->prophesize(NormalizerInterface::class); + $normalizerProphecy->normalize(Argument::type(DocumentationInterface::class), 'json', Argument::type('array'))->willReturn(self::SPEC)->shouldBeCalled(); + + $twigProphecy = $this->prophesize(TwigEnvironment::class); + $twigProphecy->render('@ApiPlatform/SwaggerUi/index.html.twig', [ + 'title' => 'title', + 'description' => '', + 'formats' => ['jsonld' => ['application/ld+json']], + 'showWebby' => true, + 'swaggerUiEnabled' => false, + 'reDocEnabled' => false, + 'graphqlEnabled' => false, + 'graphiQlEnabled' => false, + 'graphQlPlaygroundEnabled' => false, + 'swagger_data' => [ + 'url' => '/url', + 'spec' => self::SPEC, + 'oauth' => [ + 'enabled' => false, + 'clientId' => '', + 'clientSecret' => '', + 'type' => '', + 'flow' => '', + 'tokenUrl' => '', + 'authorizationUrl' => '', + 'scopes' => [], + ], + ], + ])->shouldBeCalled(); + + $urlGeneratorProphecy = $this->prophesize(UrlGenerator::class); + $urlGeneratorProphecy->generate('api_doc', ['format' => 'json'])->willReturn('/url')->shouldBeCalled(); + + $openApiFactoryProphecy = $this->prophesize(OpenApiFactoryInterface::class); + $openApiFactoryProphecy->__invoke(Argument::type('array'))->willReturn(new OpenApi(new Info('title', '1.0.0'), [], new Paths()))->shouldBeCalled(); + + $action = new SwaggerUiAction( + $resourceMetadataFactoryProphecy->reveal(), + $twigProphecy->reveal(), + $urlGeneratorProphecy->reveal(), + $normalizerProphecy->reveal(), + $openApiFactoryProphecy->reveal(), + new Options('title', '', '1.0.0'), + new SwaggerUiContext(), + ['jsonld' => ['application/ld+json']] + ); + $action($request); + } + + public function getDoNotRunCurrentRequestParameters(): iterable + { + $nonSafeRequest = new Request([], [], ['_api_resource_class' => 'Foo', '_api_collection_operation_name' => 'post']); + $nonSafeRequest->setMethod('POST'); + yield [$nonSafeRequest]; + yield [new Request()]; + } +} From 22ce6399180f9408f19b0b3c857317d2ddd7ea9d Mon Sep 17 00:00:00 2001 From: Lucas Granberg Date: Mon, 27 Jul 2020 17:32:05 +0300 Subject: [PATCH 105/160] Update InheritedPropertyNameCollectionFactory.php (#3642) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Changed depth and maxDepth handling for selfreferencing subresourceOp… (#3616) * Changed depth and maxDepth handling for selfreferencing subresourceOperations. Addresses: https://github.com/api-platform/core/issues/2533. * Use single name for subpath * fix: address @rkopera comments Co-authored-by: Clemens Pflaum * Update InheritedPropertyNameCollectionFactory.php Fix InheritedPropertyNameCollectionFactory to include parent properties and not properties of children. Fixes issue https://github.com/api-platform/core/issues/3278 Co-authored-by: Antoine Bluchet Co-authored-by: Clemens Pflaum --- ...InheritedPropertyNameCollectionFactory.php | 2 +- .../Factory/SubresourceOperationFactory.php | 12 +- ...ritedPropertyNameCollectionFactoryTest.php | 21 ++- .../SubresourceOperationFactoryTest.php | 140 +++++++++++++++++- 4 files changed, 163 insertions(+), 12 deletions(-) diff --git a/src/Metadata/Property/Factory/InheritedPropertyNameCollectionFactory.php b/src/Metadata/Property/Factory/InheritedPropertyNameCollectionFactory.php index 63f64bf49da..36c16cee917 100644 --- a/src/Metadata/Property/Factory/InheritedPropertyNameCollectionFactory.php +++ b/src/Metadata/Property/Factory/InheritedPropertyNameCollectionFactory.php @@ -53,7 +53,7 @@ public function create(string $resourceClass, array $options = []): PropertyName continue; } - if (is_subclass_of($knownResourceClass, $resourceClass)) { + if (is_subclass_of($resourceClass, $knownResourceClass)) { foreach ($this->create($knownResourceClass) as $propertyName) { $propertyNames[$propertyName] = $propertyName; } diff --git a/src/Operation/Factory/SubresourceOperationFactory.php b/src/Operation/Factory/SubresourceOperationFactory.php index ece32c2240f..227b9d494fb 100644 --- a/src/Operation/Factory/SubresourceOperationFactory.php +++ b/src/Operation/Factory/SubresourceOperationFactory.php @@ -85,12 +85,6 @@ private function computeSubresourceOperations(string $resourceClass, array &$tre $visiting = "$resourceClass $property $subresourceClass"; // Handle maxDepth - if ($rootResourceClass === $resourceClass) { - $maxDepth = $subresource->getMaxDepth(); - // reset depth when we return to rootResourceClass - $depth = 0; - } - if (null !== $maxDepth && $depth >= $maxDepth) { break; } @@ -183,7 +177,11 @@ private function computeSubresourceOperations(string $resourceClass, array &$tre $tree[$operation['route_name']] = $operation; - $this->computeSubresourceOperations($subresourceClass, $tree, $rootResourceClass, $operation, $visited + [$visiting => true], ++$depth, $maxDepth); + // Get the minimum maxDepth between the rootMaxDepth and the maxDepth of the to be visited Subresource + $currentMaxDepth = array_filter([$maxDepth, $subresource->getMaxDepth()], 'is_int'); + $currentMaxDepth = empty($currentMaxDepth) ? null : min($currentMaxDepth); + + $this->computeSubresourceOperations($subresourceClass, $tree, $rootResourceClass, $operation, $visited + [$visiting => true], $depth + 1, $currentMaxDepth); } } } diff --git a/tests/Metadata/Property/Factory/InheritedPropertyNameCollectionFactoryTest.php b/tests/Metadata/Property/Factory/InheritedPropertyNameCollectionFactoryTest.php index 9660ad1841b..d09ba6aa4b3 100644 --- a/tests/Metadata/Property/Factory/InheritedPropertyNameCollectionFactoryTest.php +++ b/tests/Metadata/Property/Factory/InheritedPropertyNameCollectionFactoryTest.php @@ -27,18 +27,33 @@ */ class InheritedPropertyNameCollectionFactoryTest extends TestCase { - public function testCreate() + public function testCreateOnParent() { $resourceNameCollectionFactory = $this->prophesize(ResourceNameCollectionFactoryInterface::class); $resourceNameCollectionFactory->create()->willReturn(new ResourceNameCollection([DummyTableInheritance::class, DummyTableInheritanceChild::class]))->shouldBeCalled(); $propertyNameCollectionFactory = $this->prophesize(PropertyNameCollectionFactoryInterface::class); $propertyNameCollectionFactory->create(DummyTableInheritance::class, [])->willReturn(new PropertyNameCollection(['name']))->shouldBeCalled(); - $propertyNameCollectionFactory->create(DummyTableInheritanceChild::class, [])->willReturn(new PropertyNameCollection(['nickname', '169']))->shouldBeCalled(); + $propertyNameCollectionFactory->create(DummyTableInheritanceChild::class, [])->shouldNotBeCalled(); $factory = new InheritedPropertyNameCollectionFactory($resourceNameCollectionFactory->reveal(), $propertyNameCollectionFactory->reveal()); $metadata = $factory->create(DummyTableInheritance::class); - $this->assertSame((array) $metadata, (array) new PropertyNameCollection(['name', 'nickname', '169'])); + $this->assertSame((array) new PropertyNameCollection(['name']), (array) $metadata); + } + + public function testCreateOnChild() + { + $resourceNameCollectionFactory = $this->prophesize(ResourceNameCollectionFactoryInterface::class); + $resourceNameCollectionFactory->create()->willReturn(new ResourceNameCollection([DummyTableInheritance::class, DummyTableInheritanceChild::class]))->shouldBeCalled(); + + $propertyNameCollectionFactory = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactory->create(DummyTableInheritance::class, [])->willReturn(new PropertyNameCollection(['name']))->shouldBeCalled(); + $propertyNameCollectionFactory->create(DummyTableInheritanceChild::class, [])->willReturn(new PropertyNameCollection(['nickname', '169']))->shouldBeCalled(); + + $factory = new InheritedPropertyNameCollectionFactory($resourceNameCollectionFactory->reveal(), $propertyNameCollectionFactory->reveal()); + $metadata = $factory->create(DummyTableInheritanceChild::class); + + $this->assertSame((array) new PropertyNameCollection(['nickname', '169', 'name']), (array) $metadata); } } diff --git a/tests/Operation/Factory/SubresourceOperationFactoryTest.php b/tests/Operation/Factory/SubresourceOperationFactoryTest.php index 74cd77841ff..292fcd4c478 100644 --- a/tests/Operation/Factory/SubresourceOperationFactoryTest.php +++ b/tests/Operation/Factory/SubresourceOperationFactoryTest.php @@ -465,7 +465,7 @@ public function testCreateWithMaxDepthMultipleSubresourcesSameMaxDepth() public function testCreateSelfReferencingSubresources() { /** - * DummyEntity -subresource-> DummyEntity -subresource-> DummyEntity ... + * DummyEntity -subresource-> DummyEntity --> DummyEntity ... */ $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); $resourceMetadataFactoryProphecy->create(DummyEntity::class)->shouldBeCalled()->willReturn(new ResourceMetadata('dummyEntity')); @@ -505,6 +505,83 @@ public function testCreateSelfReferencingSubresources() ], $subresourceOperationFactory->create(DummyEntity::class)); } + /** + * Test for issue: https://github.com/api-platform/core/issues/2533. + */ + public function testCreateWithDifferentMaxDepthSelfReferencingSubresources() + { + /** + * subresource: maxDepth = 2 + * secondSubresource: maxDepth = 1 + * DummyEntity -subresource-> DummyEntity -secondSubresource-> DummyEntity ... + * DummyEntity -secondSubresource-> DummyEntity !!!-subresource-> DummyEntity ... + */ + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataFactoryProphecy->create(DummyEntity::class)->shouldBeCalled()->willReturn(new ResourceMetadata('dummyEntity')); + + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(DummyEntity::class)->shouldBeCalled()->willReturn(new PropertyNameCollection(['subresource', 'secondSubresource'])); + + $subresourceWithMaxDepthMetadata = (new PropertyMetadata())->withSubresource(new SubresourceMetadata(DummyEntity::class, false, 2)); + $secondSubresourceWithMaxDepthMetadata = (new PropertyMetadata())->withSubresource(new SubresourceMetadata(DummyEntity::class, false, 1)); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(DummyEntity::class, 'subresource')->shouldBeCalled()->willReturn($subresourceWithMaxDepthMetadata); + $propertyMetadataFactoryProphecy->create(DummyEntity::class, 'secondSubresource')->shouldBeCalled()->willReturn($secondSubresourceWithMaxDepthMetadata); + + $pathSegmentNameGeneratorProphecy = $this->prophesize(PathSegmentNameGeneratorInterface::class); + $pathSegmentNameGeneratorProphecy->getSegmentName('dummyEntity')->shouldBeCalled()->willReturn('dummy_entities'); + $pathSegmentNameGeneratorProphecy->getSegmentName('subresource', false)->shouldBeCalled()->willReturn('subresources'); + $pathSegmentNameGeneratorProphecy->getSegmentName('secondSubresource', false)->shouldBeCalled()->willReturn('second_subresources'); + + $subresourceOperationFactory = new SubresourceOperationFactory( + $resourceMetadataFactoryProphecy->reveal(), + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $pathSegmentNameGeneratorProphecy->reveal() + ); + + $this->assertEquals([ + 'api_dummy_entities_subresource_get_subresource' => [ + 'property' => 'subresource', + 'collection' => false, + 'resource_class' => DummyEntity::class, + 'shortNames' => ['dummyEntity'], + 'identifiers' => [ + ['id', DummyEntity::class, true], + ], + 'route_name' => 'api_dummy_entities_subresource_get_subresource', + 'path' => '/dummy_entities/{id}/subresources.{_format}', + 'operation_name' => 'subresource_get_subresource', + ] + SubresourceOperationFactory::ROUTE_OPTIONS, + 'api_dummy_entities_subresource_second_subresource_get_subresource' => [ + 'property' => 'secondSubresource', + 'collection' => false, + 'resource_class' => DummyEntity::class, + 'shortNames' => ['dummyEntity'], + 'identifiers' => [ + ['id', DummyEntity::class, true], + ['subresource', DummyEntity::class, false], + ], + 'route_name' => 'api_dummy_entities_subresource_second_subresource_get_subresource', + 'path' => '/dummy_entities/{id}/subresources/second_subresources.{_format}', + 'operation_name' => 'subresource_second_subresource_get_subresource', + ] + SubresourceOperationFactory::ROUTE_OPTIONS, + 'api_dummy_entities_second_subresource_get_subresource' => [ + 'property' => 'secondSubresource', + 'collection' => false, + 'resource_class' => DummyEntity::class, + 'shortNames' => ['dummyEntity'], + 'identifiers' => [ + ['id', DummyEntity::class, true], + ], + 'route_name' => 'api_dummy_entities_second_subresource_get_subresource', + 'path' => '/dummy_entities/{id}/second_subresources.{_format}', + 'operation_name' => 'second_subresource_get_subresource', + ] + SubresourceOperationFactory::ROUTE_OPTIONS, + ], $subresourceOperationFactory->create(DummyEntity::class)); + } + public function testCreateWithEnd() { $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); @@ -652,4 +729,65 @@ public function testCreateWithRootResourcePrefix() ] + SubresourceOperationFactory::ROUTE_OPTIONS, ], $subresourceOperationFactory->create(DummyEntity::class)); } + + public function testCreateSelfReferencingSubresourcesWithSubresources() + { + /** + * DummyEntity -otherSubresource-> RelatedDummyEntity + * DummyEntity -subresource (maxDepth=1) -> DummyEntity -otherSubresource-> RelatedDummyEntity. + */ + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataFactoryProphecy->create(DummyEntity::class)->shouldBeCalled()->willReturn(new ResourceMetadata('dummyEntity')); + $resourceMetadataFactoryProphecy->create(RelatedDummyEntity::class)->shouldBeCalled()->willReturn(new ResourceMetadata('relatedDummyEntity')); + + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(DummyEntity::class)->shouldBeCalled()->willReturn(new PropertyNameCollection(['subresource', 'otherSubresource'])); + $propertyNameCollectionFactoryProphecy->create(RelatedDummyEntity::class)->shouldBeCalled()->willReturn(new PropertyNameCollection([])); + + $subresource = (new PropertyMetadata())->withSubresource(new SubresourceMetadata(DummyEntity::class, false, 1)); + $otherSubresourceSubresource = (new PropertyMetadata())->withSubresource(new SubresourceMetadata(RelatedDummyEntity::class, false)); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(DummyEntity::class, 'subresource')->shouldBeCalled()->willReturn($subresource); + $propertyMetadataFactoryProphecy->create(DummyEntity::class, 'otherSubresource')->shouldBeCalled()->willReturn($otherSubresourceSubresource); + + $pathSegmentNameGeneratorProphecy = $this->prophesize(PathSegmentNameGeneratorInterface::class); + $pathSegmentNameGeneratorProphecy->getSegmentName('dummyEntity')->shouldBeCalled()->willReturn('dummy_entities'); + $pathSegmentNameGeneratorProphecy->getSegmentName('subresource', false)->shouldBeCalled()->willReturn('subresources'); + $pathSegmentNameGeneratorProphecy->getSegmentName('otherSubresource', false)->shouldBeCalled()->willReturn('other_subresources'); + + $subresourceOperationFactory = new SubresourceOperationFactory( + $resourceMetadataFactoryProphecy->reveal(), + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $pathSegmentNameGeneratorProphecy->reveal() + ); + + $this->assertEquals([ + 'api_dummy_entities_subresource_get_subresource' => [ + 'property' => 'subresource', + 'collection' => false, + 'resource_class' => DummyEntity::class, + 'shortNames' => ['dummyEntity'], + 'identifiers' => [ + ['id', DummyEntity::class, true], + ], + 'route_name' => 'api_dummy_entities_subresource_get_subresource', + 'path' => '/dummy_entities/{id}/subresources.{_format}', + 'operation_name' => 'subresource_get_subresource', + ] + SubresourceOperationFactory::ROUTE_OPTIONS, + 'api_dummy_entities_other_subresource_get_subresource' => [ + 'property' => 'otherSubresource', + 'collection' => false, + 'resource_class' => RelatedDummyEntity::class, + 'shortNames' => ['relatedDummyEntity', 'dummyEntity'], + 'identifiers' => [ + ['id', DummyEntity::class, true], + ], + 'route_name' => 'api_dummy_entities_other_subresource_get_subresource', + 'path' => '/dummy_entities/{id}/other_subresources.{_format}', + 'operation_name' => 'other_subresource_get_subresource', + ] + SubresourceOperationFactory::ROUTE_OPTIONS, + ], $subresourceOperationFactory->create(DummyEntity::class)); + } } From 2c87089c4330cd2f571f023fbcc36b5edd2b4dbb Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Tue, 1 Sep 2020 09:44:28 +0200 Subject: [PATCH 106/160] Fix tests (#3698) * Use symfony/serializer >=4.4.9-5.0.9 to fix issues https://github.com/symfony/symfony/issues/34455 https://github.com/symfony/symfony/pull/36601 * Lowest + legacy test suite missing git * fix Symfony 5 router generate with reference type --- .github/workflows/ci.yml | 1 + composer.json | 2 +- src/Bridge/Symfony/Routing/IriConverter.php | 3 ++- tests/Bridge/Symfony/Routing/IriConverterTest.php | 12 ++++++------ 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9eb23cb959a..91b997d1406 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -829,6 +829,7 @@ jobs: - name: Install system packages run: | apk add \ + git \ unzip \ - name: Cache mongodb PHP extension build uses: actions/cache@v2 diff --git a/composer.json b/composer.json index 188d84d30b1..20be9be55ca 100644 --- a/composer.json +++ b/composer.json @@ -22,7 +22,7 @@ "symfony/http-kernel": "^4.3.7 || ^5.0", "symfony/property-access": "^3.4 || ^4.0 || ^5.0", "symfony/property-info": "^3.4 || ^4.0 || ^5.0", - "symfony/serializer": "^4.4 || ^5.0", + "symfony/serializer": "^4.4.9 || ^5.0.9", "symfony/web-link": "^4.1 || ^5.0", "willdurand/negotiation": "^2.0.3" }, diff --git a/src/Bridge/Symfony/Routing/IriConverter.php b/src/Bridge/Symfony/Routing/IriConverter.php index 1e031609f8a..e0c8e5c7211 100644 --- a/src/Bridge/Symfony/Routing/IriConverter.php +++ b/src/Bridge/Symfony/Routing/IriConverter.php @@ -18,6 +18,7 @@ use ApiPlatform\Core\Api\IriConverterInterface; use ApiPlatform\Core\Api\OperationType; use ApiPlatform\Core\Api\ResourceClassResolverInterface; +use ApiPlatform\Core\Api\UrlGeneratorInterface; use ApiPlatform\Core\DataProvider\ItemDataProviderInterface; use ApiPlatform\Core\DataProvider\OperationDataProviderTrait; use ApiPlatform\Core\DataProvider\SubresourceDataProviderInterface; @@ -200,6 +201,6 @@ private function getReferenceType(string $resourceClass, ?int $referenceType): ? $referenceType = $metadata->getAttribute('url_generation_strategy'); } - return $referenceType; + return $referenceType ?? UrlGeneratorInterface::ABS_PATH; } } diff --git a/tests/Bridge/Symfony/Routing/IriConverterTest.php b/tests/Bridge/Symfony/Routing/IriConverterTest.php index e8fe2cec17f..dbf51036f9b 100644 --- a/tests/Bridge/Symfony/Routing/IriConverterTest.php +++ b/tests/Bridge/Symfony/Routing/IriConverterTest.php @@ -144,7 +144,7 @@ public function testGetIriFromResourceClass() $routeNameResolverProphecy->getRouteName(Dummy::class, OperationType::COLLECTION)->willReturn('dummies'); $routerProphecy = $this->prophesize(RouterInterface::class); - $routerProphecy->generate('dummies', [], null)->willReturn('/dummies'); + $routerProphecy->generate('dummies', [], UrlGeneratorInterface::ABS_PATH)->willReturn('/dummies'); $converter = $this->getIriConverter($routerProphecy, $routeNameResolverProphecy); $this->assertEquals($converter->getIriFromResourceClass(Dummy::class), '/dummies'); @@ -174,7 +174,7 @@ public function testNotAbleToGenerateGetIriFromResourceClass() $routeNameResolverProphecy->getRouteName(Dummy::class, OperationType::COLLECTION)->willReturn('dummies'); $routerProphecy = $this->prophesize(RouterInterface::class); - $routerProphecy->generate('dummies', [], null)->willThrow(new RouteNotFoundException()); + $routerProphecy->generate('dummies', [], UrlGeneratorInterface::ABS_PATH)->willThrow(new RouteNotFoundException()); $converter = $this->getIriConverter($routerProphecy, $routeNameResolverProphecy); $converter->getIriFromResourceClass(Dummy::class); @@ -186,7 +186,7 @@ public function testGetSubresourceIriFromResourceClass() $routeNameResolverProphecy->getRouteName(Dummy::class, OperationType::SUBRESOURCE, Argument::type('array'))->willReturn('api_dummies_related_dummies_get_subresource'); $routerProphecy = $this->prophesize(RouterInterface::class); - $routerProphecy->generate('api_dummies_related_dummies_get_subresource', ['id' => 1], null)->willReturn('/dummies/1/related_dummies'); + $routerProphecy->generate('api_dummies_related_dummies_get_subresource', ['id' => 1], UrlGeneratorInterface::ABS_PATH)->willReturn('/dummies/1/related_dummies'); $converter = $this->getIriConverter($routerProphecy, $routeNameResolverProphecy); $this->assertEquals($converter->getSubresourceIriFromResourceClass(Dummy::class, ['subresource_identifiers' => ['id' => 1], 'subresource_resources' => [RelatedDummy::class => 1]]), '/dummies/1/related_dummies'); @@ -201,7 +201,7 @@ public function testNotAbleToGenerateGetSubresourceIriFromResourceClass() $routeNameResolverProphecy->getRouteName(Dummy::class, OperationType::SUBRESOURCE, Argument::type('array'))->willReturn('dummies'); $routerProphecy = $this->prophesize(RouterInterface::class); - $routerProphecy->generate('dummies', ['id' => 1], null)->willThrow(new RouteNotFoundException()); + $routerProphecy->generate('dummies', ['id' => 1], UrlGeneratorInterface::ABS_PATH)->willThrow(new RouteNotFoundException()); $converter = $this->getIriConverter($routerProphecy, $routeNameResolverProphecy); $converter->getSubresourceIriFromResourceClass(Dummy::class, ['subresource_identifiers' => ['id' => 1], 'subresource_resources' => [RelatedDummy::class => 1]]); @@ -213,7 +213,7 @@ public function testGetItemIriFromResourceClass() $routeNameResolverProphecy->getRouteName(Dummy::class, OperationType::ITEM)->willReturn('api_dummies_get_item'); $routerProphecy = $this->prophesize(RouterInterface::class); - $routerProphecy->generate('api_dummies_get_item', ['id' => 1], null)->willReturn('/dummies/1'); + $routerProphecy->generate('api_dummies_get_item', ['id' => 1], UrlGeneratorInterface::ABS_PATH)->willReturn('/dummies/1'); $converter = $this->getIriConverter($routerProphecy, $routeNameResolverProphecy); $this->assertEquals($converter->getItemIriFromResourceClass(Dummy::class, ['id' => 1]), '/dummies/1'); @@ -243,7 +243,7 @@ public function testNotAbleToGenerateGetItemIriFromResourceClass() $routeNameResolverProphecy->getRouteName(Dummy::class, OperationType::ITEM)->willReturn('dummies'); $routerProphecy = $this->prophesize(RouterInterface::class); - $routerProphecy->generate('dummies', ['id' => 1], null)->willThrow(new RouteNotFoundException()); + $routerProphecy->generate('dummies', ['id' => 1], UrlGeneratorInterface::ABS_PATH)->willThrow(new RouteNotFoundException()); $converter = $this->getIriConverter($routerProphecy, $routeNameResolverProphecy); $converter->getItemIriFromResourceClass(Dummy::class, ['id' => 1]); From 3dd0f7e023987a8f4f22a21685da40666288ce29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=BCcher?= Date: Mon, 7 Sep 2020 22:30:13 +0200 Subject: [PATCH 107/160] Fix definition names to be compliant with OpenAPI 3.0 and JSON Point Spec (#3705) * Replace all characters other than `^[a-zA-Z0-9\.\-_]+$.` to `.` in definition names to be compliant with OpenAPI 3.0 * Fix definition name regex --- CHANGELOG.md | 1 + features/openapi/docs.feature | 2 +- src/JsonSchema/SchemaFactory.php | 11 ++++++++--- tests/Hydra/JsonSchema/SchemaFactoryTest.php | 6 +++--- .../Serializer/DocumentationNormalizerV2Test.php | 16 ++++++++-------- 5 files changed, 21 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d13ea2aeb93..d5d2fccd95c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ * Deprecate using a validation groups generator service not implementing `ApiPlatform\Core\Bridge\Symfony\Validator\ValidationGroupsGeneratorInterface` (#3346) * Subresources: subresource resourceClass can now be defined as a container parameter in XML and Yaml definitions * IriConverter: Fix IRI url double encoding - may cause breaking change as some characters no longer encoded in output (#3552) +* OpenAPI: **BC** Replace all characters other than `[a-zA-Z0-9\.\-_]` to `.` in definition names to be compliant with OpenAPI 3.0 (#3669) ## 2.5.7 diff --git a/features/openapi/docs.feature b/features/openapi/docs.feature index e3c27d0a220..814e834421f 100644 --- a/features/openapi/docs.feature +++ b/features/openapi/docs.feature @@ -95,7 +95,7 @@ Feature: Documentation support And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters" should have 6 elements # Subcollection - check schema - And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.responses.200.content.application/ld+json.schema.properties.hydra:member.items.$ref" should be equal to "#/components/schemas/RelatedToDummyFriend:jsonld-fakemanytomany" + And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.responses.200.content.application/ld+json.schema.properties.hydra:member.items.$ref" should be equal to "#/components/schemas/RelatedToDummyFriend.jsonld-fakemanytomany" # Deprecations And the JSON node "paths./dummies.get.deprecated" should not exist diff --git a/src/JsonSchema/SchemaFactory.php b/src/JsonSchema/SchemaFactory.php index ac582f45be1..1c7183637e1 100644 --- a/src/JsonSchema/SchemaFactory.php +++ b/src/JsonSchema/SchemaFactory.php @@ -235,12 +235,12 @@ private function buildDefinitionName(string $className, string $format = 'json', if (null !== $inputOrOutputClass && $className !== $inputOrOutputClass) { $parts = explode('\\', $inputOrOutputClass); $shortName = end($parts); - $prefix .= ':'.$shortName; + $prefix .= '.'.$shortName; } if (isset($this->distinctFormats[$format])) { // JSON is the default, and so isn't included in the definition name - $prefix .= ':'.$format; + $prefix .= '.'.$format; } if (isset($serializerContext[DocumentationNormalizer::SWAGGER_DEFINITION_NAME])) { @@ -250,7 +250,12 @@ private function buildDefinitionName(string $className, string $format = 'json', $name = $groups ? sprintf('%s-%s', $prefix, implode('_', $groups)) : $prefix; } - return $name; + return $this->encodeDefinitionName($name); + } + + private function encodeDefinitionName(string $name): string + { + return preg_replace('/[^a-zA-Z0-9.\-_]/', '.', $name); } private function getMetadata(string $className, string $type = Schema::TYPE_OUTPUT, ?string $operationType, ?string $operationName, ?array $serializerContext): ?array diff --git a/tests/Hydra/JsonSchema/SchemaFactoryTest.php b/tests/Hydra/JsonSchema/SchemaFactoryTest.php index 2a2e4a67de4..3abcca58c59 100644 --- a/tests/Hydra/JsonSchema/SchemaFactoryTest.php +++ b/tests/Hydra/JsonSchema/SchemaFactoryTest.php @@ -57,7 +57,7 @@ public function testBuildSchema(): void $resultSchema = $this->schemaFactory->buildSchema(Dummy::class); $this->assertTrue($resultSchema->isDefined()); - $this->assertEquals(Dummy::class.':jsonld', $resultSchema->getRootDefinitionKey()); + $this->assertEquals(str_replace('\\', '.', Dummy::class).'.jsonld', $resultSchema->getRootDefinitionKey()); } public function testCustomFormatBuildSchema(): void @@ -65,7 +65,7 @@ public function testCustomFormatBuildSchema(): void $resultSchema = $this->schemaFactory->buildSchema(Dummy::class, 'json'); $this->assertTrue($resultSchema->isDefined()); - $this->assertEquals(Dummy::class, $resultSchema->getRootDefinitionKey()); + $this->assertEquals(str_replace('\\', '.', Dummy::class), $resultSchema->getRootDefinitionKey()); } public function testHasRootDefinitionKeyBuildSchema(): void @@ -74,7 +74,7 @@ public function testHasRootDefinitionKeyBuildSchema(): void $definitions = $resultSchema->getDefinitions(); $rootDefinitionKey = $resultSchema->getRootDefinitionKey(); - $this->assertEquals(Dummy::class.':jsonld', $rootDefinitionKey); + $this->assertEquals(str_replace('\\', '.', Dummy::class).'.jsonld', $rootDefinitionKey); $this->assertArrayHasKey($rootDefinitionKey, $definitions); $this->assertArrayHasKey('properties', $definitions[$rootDefinitionKey]); } diff --git a/tests/Swagger/Serializer/DocumentationNormalizerV2Test.php b/tests/Swagger/Serializer/DocumentationNormalizerV2Test.php index 6805776daac..30f1c0ffcb8 100644 --- a/tests/Swagger/Serializer/DocumentationNormalizerV2Test.php +++ b/tests/Swagger/Serializer/DocumentationNormalizerV2Test.php @@ -2779,7 +2779,7 @@ private function doTestNormalizeWithInputAndOutputClass(): void 'schema' => [ 'type' => 'array', 'items' => [ - '$ref' => '#/definitions/Dummy:OutputDto', + '$ref' => '#/definitions/Dummy.OutputDto', ], ], ], @@ -2804,7 +2804,7 @@ private function doTestNormalizeWithInputAndOutputClass(): void 201 => [ 'description' => 'Dummy resource created', 'schema' => [ - '$ref' => '#/definitions/Dummy:OutputDto', + '$ref' => '#/definitions/Dummy.OutputDto', ], ], 400 => [ @@ -2820,7 +2820,7 @@ private function doTestNormalizeWithInputAndOutputClass(): void 'in' => 'body', 'description' => 'The new Dummy resource', 'schema' => [ - '$ref' => '#/definitions/Dummy:InputDto', + '$ref' => '#/definitions/Dummy.InputDto', ], ], ], @@ -2844,7 +2844,7 @@ private function doTestNormalizeWithInputAndOutputClass(): void 200 => [ 'description' => 'Dummy resource response', 'schema' => [ - '$ref' => '#/definitions/Dummy:OutputDto', + '$ref' => '#/definitions/Dummy.OutputDto', ], ], 404 => [ @@ -2870,7 +2870,7 @@ private function doTestNormalizeWithInputAndOutputClass(): void 'in' => 'body', 'description' => 'The updated Dummy resource', 'schema' => [ - '$ref' => '#/definitions/Dummy:InputDto', + '$ref' => '#/definitions/Dummy.InputDto', ], ], ], @@ -2878,7 +2878,7 @@ private function doTestNormalizeWithInputAndOutputClass(): void 200 => [ 'description' => 'Dummy resource updated', 'schema' => [ - '$ref' => '#/definitions/Dummy:OutputDto', + '$ref' => '#/definitions/Dummy.OutputDto', ], ], 400 => [ @@ -2892,7 +2892,7 @@ private function doTestNormalizeWithInputAndOutputClass(): void ], ]), 'definitions' => new \ArrayObject([ - 'Dummy:OutputDto' => new \ArrayObject([ + 'Dummy.OutputDto' => new \ArrayObject([ 'type' => 'object', 'description' => 'This is a dummy.', 'externalDocs' => [ @@ -2910,7 +2910,7 @@ private function doTestNormalizeWithInputAndOutputClass(): void ]), ], ]), - 'Dummy:InputDto' => new \ArrayObject([ + 'Dummy.InputDto' => new \ArrayObject([ 'type' => 'object', 'description' => 'This is a dummy.', 'externalDocs' => [ From f1a26db4569d86a7ca5d63a93ac022abbd3a2deb Mon Sep 17 00:00:00 2001 From: Alan Poulain Date: Tue, 8 Sep 2020 15:44:41 +0200 Subject: [PATCH 108/160] [GraphQL] deprecationReason should be null when not set (#3712) --- src/GraphQl/Type/FieldsBuilder.php | 4 ++-- tests/GraphQl/Type/FieldsBuilderTest.php | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/GraphQl/Type/FieldsBuilder.php b/src/GraphQl/Type/FieldsBuilder.php index b904771123b..85e9d24ac62 100644 --- a/src/GraphQl/Type/FieldsBuilder.php +++ b/src/GraphQl/Type/FieldsBuilder.php @@ -96,7 +96,7 @@ public function getItemQueryFields(string $resourceClass, ResourceMetadata $reso $shortName = $resourceMetadata->getShortName(); $fieldName = lcfirst('item_query' === $queryName ? $shortName : $queryName.$shortName); $description = $resourceMetadata->getGraphqlAttribute($queryName, 'description'); - $deprecationReason = (string) $resourceMetadata->getGraphqlAttribute($queryName, 'deprecation_reason', null, true); + $deprecationReason = $resourceMetadata->getGraphqlAttribute($queryName, 'deprecation_reason', null, true); if ($fieldConfiguration = $this->getResourceFieldConfiguration(null, $description, $deprecationReason, new Type(Type::BUILTIN_TYPE_OBJECT, true, $resourceClass), $resourceClass, false, $queryName, null, null)) { $args = $this->resolveResourceArgs($configuration['args'] ?? [], $queryName, $shortName); @@ -116,7 +116,7 @@ public function getCollectionQueryFields(string $resourceClass, ResourceMetadata $shortName = $resourceMetadata->getShortName(); $fieldName = lcfirst('collection_query' === $queryName ? $shortName : $queryName.$shortName); $description = $resourceMetadata->getGraphqlAttribute($queryName, 'description'); - $deprecationReason = (string) $resourceMetadata->getGraphqlAttribute($queryName, 'deprecation_reason', null, true); + $deprecationReason = $resourceMetadata->getGraphqlAttribute($queryName, 'deprecation_reason', null, true); if ($fieldConfiguration = $this->getResourceFieldConfiguration(null, $description, $deprecationReason, new Type(Type::BUILTIN_TYPE_OBJECT, false, null, true, null, new Type(Type::BUILTIN_TYPE_OBJECT, false, $resourceClass)), $resourceClass, false, $queryName, null, null)) { $args = $this->resolveResourceArgs($configuration['args'] ?? [], $queryName, $shortName); diff --git a/tests/GraphQl/Type/FieldsBuilderTest.php b/tests/GraphQl/Type/FieldsBuilderTest.php index 101bc7f8040..6fe5922e969 100644 --- a/tests/GraphQl/Type/FieldsBuilderTest.php +++ b/tests/GraphQl/Type/FieldsBuilderTest.php @@ -355,7 +355,7 @@ public function collectionQueryFieldsProvider(): array 'dateField' => new InputObjectType(['name' => 'ShortNameFilter_dateField', 'fields' => ['before' => $graphqlType]]), ], 'resolve' => $resolver, - 'deprecationReason' => '', + 'deprecationReason' => null, ], ], ], @@ -414,11 +414,11 @@ public function mutationFieldsProvider(): array 'description' => null, 'args' => [], 'resolve' => null, - 'deprecationReason' => '', + 'deprecationReason' => null, ], ], 'resolve' => $mutationResolver, - 'deprecationReason' => '', + 'deprecationReason' => null, ], ], ], @@ -567,7 +567,7 @@ public function resourceObjectTypeFieldsProvider(): array 'description' => null, 'args' => [], 'resolve' => null, - 'deprecationReason' => '', + 'deprecationReason' => null, ], ], $advancedNameConverter->reveal(), @@ -710,7 +710,7 @@ public function resourceObjectTypeFieldsProvider(): array 'description' => null, 'args' => [], 'resolve' => null, - 'deprecationReason' => '', + 'deprecationReason' => null, ], ], ], From 1e62c994572e91fc9c34cf44086d91989d5b2760 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Thu, 10 Sep 2020 14:49:39 +0200 Subject: [PATCH 109/160] Merge 2.5 onto master (#3713) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix prophecy trait (#3700) * fix prophecy trait * Support multiple phpunit version ArraySubset (#3702) * fix: restore coverage * Fix phpstan * Remove php-code-coverage dependency * Support multiple phpunit version ArraySubset Co-authored-by: soyuka Co-authored-by: Grégoire Hébert Co-authored-by: Kévin Dunglas --- .github/workflows/ci.yml | 2 + phpstan.neon.dist | 7 +- .../Bundle/Test/Constraint/ArraySubset.php | 90 ++--------------- .../Test/Constraint/ArraySubsetLegacy.php | 34 +++++++ .../Test/Constraint/ArraySubsetTrait.php | 98 +++++++++++++++++++ .../Bundle/Test/Constraint/ArraySubsetV9.php | 34 +++++++ tests/ProphecyTrait.php | 1 + 7 files changed, 181 insertions(+), 85 deletions(-) create mode 100644 src/Bridge/Symfony/Bundle/Test/Constraint/ArraySubsetLegacy.php create mode 100644 src/Bridge/Symfony/Bundle/Test/Constraint/ArraySubsetTrait.php create mode 100644 src/Bridge/Symfony/Bundle/Test/Constraint/ArraySubsetV9.php diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 91b997d1406..064398987e7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -137,6 +137,8 @@ jobs: phpstan- continue-on-error: true - name: Run PHPStan analysis + env: + SYMFONY_PHPUNIT_VERSION: '9.2' run: vendor/bin/phpstan analyse --no-progress --no-interaction --ansi phpunit: diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 2ab76817b93..4cd9ea3abb1 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -9,15 +9,20 @@ parameters: container_xml_path: tests/Fixtures/app/var/cache/test/appAppKernelTestDebugContainer.xml constant_hassers: false bootstrapFiles: - - vendor/bin/.phpunit/phpunit-9.2-0/vendor/autoload.php + - vendor/bin/.phpunit/phpunit/vendor/autoload.php + # We're aliasing classes for phpunit in this file, it needs to be added here see phpstan/#2194 + - src/Bridge/Symfony/Bundle/Test/Constraint/ArraySubset.php - tests/Fixtures/app/AppKernel.php excludes_analyse: - tests/Fixtures/app/var/cache # The Symfony Configuration API isn't good enough to be analysed - src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php + # Phpstan runs on phpunit > 9, a signature changed in this file + - src/Bridge/Symfony/Bundle/Test/Constraint/ArraySubsetLegacy.php # Imported code (temporary) - src/Bridge/Symfony/Bundle/Test/BrowserKitAssertionsTrait.php - tests/Bridge/Symfony/Bundle/Test/WebTestCaseTest.php + - tests/ProphecyTrait.php earlyTerminatingMethodCalls: PHPUnit\Framework\Constraint\Constraint: - fail diff --git a/src/Bridge/Symfony/Bundle/Test/Constraint/ArraySubset.php b/src/Bridge/Symfony/Bundle/Test/Constraint/ArraySubset.php index 32df4f3c04c..af1e89e0b94 100644 --- a/src/Bridge/Symfony/Bundle/Test/Constraint/ArraySubset.php +++ b/src/Bridge/Symfony/Bundle/Test/Constraint/ArraySubset.php @@ -13,94 +13,16 @@ namespace ApiPlatform\Core\Bridge\Symfony\Bundle\Test\Constraint; -use PHPUnit\Framework\Constraint\Constraint; use PHPUnit\SebastianBergmann\Comparator\ComparisonFailure; use SebastianBergmann\Comparator\ComparisonFailure as LegacyComparisonFailure; if (!class_exists(ComparisonFailure::class)) { - class_alias(LegacyComparisonFailure::class, ComparisonFailure::class); + class_alias(LegacyComparisonFailure::class, 'PHPUnit\SebastianBergmann\Comparator\ComparisonFailure'); } -/** - * Constraint that asserts that the array it is evaluated for has a specified subset. - * - * Uses array_replace_recursive() to check if a key value subset is part of the - * subject array. - * - * Imported from dms/phpunit-arraysubset-asserts, because the original constraint has been deprecated. - * - * @copyright Sebastian Bergmann - * @copyright Rafael Dohms - * - * @see https://github.com/sebastianbergmann/phpunit/issues/3494 - */ -final class ArraySubset extends Constraint -{ - private $subset; - private $strict; - - public function __construct(iterable $subset, bool $strict = false) - { - $this->strict = $strict; - $this->subset = $subset; - } - - /** - * {@inheritdoc} - */ - public function evaluate($other, string $description = '', bool $returnResult = false): ?bool - { - //type cast $other & $this->subset as an array to allow - //support in standard array functions. - $other = $this->toArray($other); - $this->subset = $this->toArray($this->subset); - $patched = array_replace_recursive($other, $this->subset); - if ($this->strict) { - $result = $other === $patched; - } else { - $result = $other == $patched; - } - if ($returnResult) { - return $result; - } - if ($result) { - return null; - } - - $f = new ComparisonFailure( - $patched, - $other, - var_export($patched, true), - var_export($other, true) - ); - $this->fail($other, $description, $f); - } - - /** - * {@inheritdoc} - */ - public function toString(): string - { - return 'has the subset '.$this->exporter()->export($this->subset); - } - - /** - * {@inheritdoc} - */ - protected function failureDescription($other): string - { - return 'an array '.$this->toString(); - } - - private function toArray(iterable $other): array - { - if (\is_array($other)) { - return $other; - } - if ($other instanceof \ArrayObject) { - return $other->getArrayCopy(); - } - - return iterator_to_array($other); - } +// Aliases as string to avoid loading the class +if (\PHP_VERSION_ID >= 80000 || (float) getenv('SYMFONY_PHPUNIT_VERSION') > 8) { + class_alias('ApiPlatform\Core\Bridge\Symfony\Bundle\Test\Constraint\ArraySubsetV9', 'ApiPlatform\Core\Bridge\Symfony\Bundle\Test\Constraint\ArraySubset'); +} else { + class_alias('ApiPlatform\Core\Bridge\Symfony\Bundle\Test\Constraint\ArraySubsetLegacy', 'ApiPlatform\Core\Bridge\Symfony\Bundle\Test\Constraint\ArraySubset'); } diff --git a/src/Bridge/Symfony/Bundle/Test/Constraint/ArraySubsetLegacy.php b/src/Bridge/Symfony/Bundle/Test/Constraint/ArraySubsetLegacy.php new file mode 100644 index 00000000000..0911672ce01 --- /dev/null +++ b/src/Bridge/Symfony/Bundle/Test/Constraint/ArraySubsetLegacy.php @@ -0,0 +1,34 @@ + + * + * 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\Bridge\Symfony\Bundle\Test\Constraint; + +use PHPUnit\Framework\Constraint\Constraint; + +/** + * Is used for phpunit < 8. + * + * @internal + */ +final class ArraySubsetLegacy extends Constraint +{ + use ArraySubsetTrait; + + /** + * {@inheritdoc} + */ + public function evaluate($other, $description = '', $returnResult = false) + { + return $this->_evaluate($other, $description, $returnResult); + } +} diff --git a/src/Bridge/Symfony/Bundle/Test/Constraint/ArraySubsetTrait.php b/src/Bridge/Symfony/Bundle/Test/Constraint/ArraySubsetTrait.php new file mode 100644 index 00000000000..a757109f3be --- /dev/null +++ b/src/Bridge/Symfony/Bundle/Test/Constraint/ArraySubsetTrait.php @@ -0,0 +1,98 @@ + + * + * 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\Bridge\Symfony\Bundle\Test\Constraint; + +use PHPUnit\Framework\Constraint\Constraint; +use PHPUnit\SebastianBergmann\Comparator\ComparisonFailure; + +/** + * Constraint that asserts that the array it is evaluated for has a specified subset. + * + * Uses array_replace_recursive() to check if a key value subset is part of the + * subject array. + * + * Imported from dms/phpunit-arraysubset-asserts, because the original constraint has been deprecated. + * + * @copyright Sebastian Bergmann + * @copyright Rafael Dohms + * + * @see https://github.com/sebastianbergmann/phpunit/issues/3494 + */ +trait ArraySubsetTrait +{ + private $subset; + private $strict; + + public function __construct(iterable $subset, bool $strict = false) + { + $this->strict = $strict; + $this->subset = $subset; + } + + private function _evaluate($other, string $description = '', bool $returnResult = false): ?bool + { + //type cast $other & $this->subset as an array to allow + //support in standard array functions. + $other = $this->toArray($other); + $this->subset = $this->toArray($this->subset); + $patched = array_replace_recursive($other, $this->subset); + if ($this->strict) { + $result = $other === $patched; + } else { + $result = $other == $patched; + } + if ($returnResult) { + return $result; + } + if ($result) { + return null; + } + + $f = new ComparisonFailure( + $patched, + $other, + var_export($patched, true), + var_export($other, true) + ); + $this->fail($other, $description, $f); + } + + /** + * {@inheritdoc} + */ + public function toString(): string + { + return 'has the subset '.$this->exporter()->export($this->subset); + } + + /** + * {@inheritdoc} + */ + protected function failureDescription($other): string + { + return 'an array '.$this->toString(); + } + + private function toArray(iterable $other): array + { + if (\is_array($other)) { + return $other; + } + if ($other instanceof \ArrayObject) { + return $other->getArrayCopy(); + } + + return iterator_to_array($other); + } +} diff --git a/src/Bridge/Symfony/Bundle/Test/Constraint/ArraySubsetV9.php b/src/Bridge/Symfony/Bundle/Test/Constraint/ArraySubsetV9.php new file mode 100644 index 00000000000..ccb16def0e6 --- /dev/null +++ b/src/Bridge/Symfony/Bundle/Test/Constraint/ArraySubsetV9.php @@ -0,0 +1,34 @@ + + * + * 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\Bridge\Symfony\Bundle\Test\Constraint; + +use PHPUnit\Framework\Constraint\Constraint; + +/** + * Is used for phpunit >= 9. + * + * @internal + */ +final class ArraySubsetV9 extends Constraint +{ + use ArraySubsetTrait; + + /** + * {@inheritdoc} + */ + public function evaluate($other, string $description = '', bool $returnResult = false): ?bool + { + return $this->_evaluate($other, $description, $returnResult); + } +} diff --git a/tests/ProphecyTrait.php b/tests/ProphecyTrait.php index 2aaaaad7fc0..439608d5a4b 100644 --- a/tests/ProphecyTrait.php +++ b/tests/ProphecyTrait.php @@ -71,6 +71,7 @@ protected function prophesize($classOrInterface = null): ObjectProphecy /** * @postCondition + * @after */ protected function verifyProphecyDoubles(): void { From 17966ce3af32661d9d571c639503b1d9ff404bcb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomas=20Nork=C5=ABnas?= Date: Thu, 10 Sep 2020 20:01:18 +0300 Subject: [PATCH 110/160] Add Symfony Uid support (#3715) --- .github/workflows/ci.yml | 39 ++++++++++++ composer.json | 1 + .../ApiPlatformExtension.php | 5 ++ .../Bundle/Resources/config/symfony_uid.xml | 16 +++++ .../Identifier/Normalizer/UlidNormalizer.php | 44 +++++++++++++ .../Identifier/Normalizer/UuidNormalizer.php | 44 +++++++++++++ src/JsonSchema/TypeFactory.php | 10 ++- .../ApiPlatformExtensionTest.php | 6 ++ .../Normalizer/UlidNormalizerTest.php | 62 +++++++++++++++++++ .../Normalizer/UuidNormalizerTest.php | 62 +++++++++++++++++++ 10 files changed, 288 insertions(+), 1 deletion(-) create mode 100644 src/Bridge/Symfony/Bundle/Resources/config/symfony_uid.xml create mode 100644 src/Bridge/Symfony/Identifier/Normalizer/UlidNormalizer.php create mode 100644 src/Bridge/Symfony/Identifier/Normalizer/UuidNormalizer.php create mode 100644 tests/Bridge/Symfony/Identifier/Normalizer/UlidNormalizerTest.php create mode 100644 tests/Bridge/Symfony/Identifier/Normalizer/UuidNormalizerTest.php diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 064398987e7..137012d8dd0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -113,6 +113,8 @@ jobs: run: | composer global require --prefer-dist --no-progress --no-suggest --ansi \ symfony/flex + - name: Require Symfony Uid + run: composer require symfony/uid --dev --no-progress --ansi - name: Update project dependencies run: | mkdir -p /tmp/api-platform/core/vendor @@ -235,6 +237,9 @@ jobs: composer remove --dev --no-progress --no-update --ansi \ doctrine/mongodb-odm \ doctrine/mongodb-odm-bundle \ + - name: Require Symfony Uid + if: (!startsWith(matrix.php, '7.1')) + run: composer require symfony/uid --dev --no-progress --ansi - name: Update project dependencies run: | mkdir -p /tmp/api-platform/core/vendor @@ -377,6 +382,9 @@ jobs: composer remove --dev --no-progress --no-update --ansi \ doctrine/mongodb-odm \ doctrine/mongodb-odm-bundle \ + - name: Require Symfony Uid + if: (!startsWith(matrix.php, '7.1')) + run: composer require symfony/uid --dev --no-progress --ansi - name: Update project dependencies run: | mkdir -p /tmp/api-platform/core/vendor @@ -521,6 +529,8 @@ jobs: run: | composer global require --prefer-dist --no-progress --no-suggest --ansi \ symfony/flex + - name: Require Symfony Uid + run: composer require symfony/uid --dev --no-progress --ansi - name: Update project dependencies run: | mkdir -p /tmp/api-platform/core/vendor @@ -613,6 +623,8 @@ jobs: mkdir -p /tmp/api-platform/core/vendor ln -s /tmp/api-platform/core/vendor vendor composer update --no-progress --no-suggest --prefer-stable --prefer-lowest --ansi + - name: Require Symfony Uid + run: composer require symfony/uid --dev --no-progress --ansi - name: Install PHPUnit run: vendor/bin/simple-phpunit --version - name: Clear test app cache @@ -753,6 +765,9 @@ jobs: composer remove --dev --no-progress --no-update --ansi \ doctrine/mongodb-odm \ doctrine/mongodb-odm-bundle \ + - name: Require Symfony Uid + if: (!startsWith(matrix.php, '7.1')) + run: composer require symfony/uid --dev --no-progress --ansi - name: Update project dependencies run: | mkdir -p /tmp/api-platform/core/vendor @@ -876,6 +891,8 @@ jobs: run: | composer global require --prefer-dist --no-progress --no-suggest --ansi \ symfony/flex + - name: Require Symfony Uid + run: composer require symfony/uid --dev --no-progress --ansi - name: Update project dependencies run: | mkdir -p /tmp/api-platform/core/vendor @@ -987,6 +1004,8 @@ jobs: run: | composer global require --prefer-dist --no-progress --no-suggest --ansi \ symfony/flex + - name: Require Symfony Uid + run: composer require symfony/uid --dev --no-progress --ansi - name: Update project dependencies run: | mkdir -p /tmp/api-platform/core/vendor @@ -1098,6 +1117,8 @@ jobs: run: | composer global require --prefer-dist --no-progress --no-suggest --ansi \ symfony/flex + - name: Require Symfony Uid + run: composer require symfony/uid --dev --no-progress --ansi - name: Update project dependencies run: | mkdir -p /tmp/api-platform/core/vendor @@ -1233,6 +1254,8 @@ jobs: run: | composer global require --prefer-dist --no-progress --no-suggest --ansi \ symfony/flex + - name: Require Symfony Uid + run: composer require symfony/uid --dev --no-progress --ansi - name: Update project dependencies run: | mkdir -p /tmp/api-platform/core/vendor @@ -1344,6 +1367,8 @@ jobs: run: | composer global require --prefer-dist --no-progress --no-suggest --ansi \ symfony/flex + - name: Require Symfony Uid + run: composer require symfony/uid --dev --no-progress --ansi - name: Update project dependencies run: | mkdir -p /tmp/api-platform/core/vendor @@ -1492,6 +1517,8 @@ jobs: mkdir -p /tmp/api-platform/core/vendor ln -s /tmp/api-platform/core/vendor vendor composer update --no-progress --no-suggest --ansi + - name: Require Symfony Uid + run: composer require symfony/uid --dev --no-progress --ansi - name: Install PHPUnit run: vendor/bin/simple-phpunit --version - name: Clear test app cache @@ -1630,6 +1657,8 @@ jobs: run: | composer global require --prefer-dist --no-progress --no-suggest --ansi \ symfony/flex + - name: Require Symfony Uid + run: composer require symfony/uid --dev --no-progress --ansi - name: Update project dependencies run: | mkdir -p /tmp/api-platform/core/vendor @@ -1806,6 +1835,8 @@ jobs: run: | composer global require --prefer-dist --no-progress --no-suggest --ansi \ symfony/flex + - name: Require Symfony Uid + run: composer require symfony/uid --dev --no-progress --ansi - name: Update project dependencies run: | mkdir -p /tmp/api-platform/core/vendor @@ -1951,6 +1982,8 @@ jobs: run: | composer global require --prefer-dist --no-progress --no-suggest --ansi \ symfony/flex + - name: Require Symfony Uid + run: composer require symfony/uid --dev --no-progress --ansi - name: Update project dependencies run: | mkdir -p /tmp/api-platform/core/vendor @@ -2094,6 +2127,8 @@ jobs: run: | composer global require --prefer-dist --no-progress --no-suggest --ansi \ symfony/flex + - name: Require Symfony Uid + run: composer require symfony/uid --dev --no-progress --ansi - name: Update project dependencies run: | mkdir -p /tmp/api-platform/core/vendor @@ -2185,6 +2220,8 @@ jobs: run: | composer global require --prefer-dist --no-progress --no-suggest --ansi \ symfony/flex + - name: Require Symfony Uid + run: composer require symfony/uid --dev --no-progress --ansi - name: Allow unstable project dependencies run: | jq '. + {"minimum-stability": "dev"}' composer.json | sponge composer.json @@ -2293,6 +2330,8 @@ jobs: run: | composer global require --prefer-dist --no-progress --no-suggest --ansi \ symfony/flex + - name: Require Symfony Uid + run: composer require symfony/uid --dev --no-progress --ansi - name: Allow unstable project dependencies run: | jq '. + {"minimum-stability": "dev"}' composer.json | sponge composer.json diff --git a/composer.json b/composer.json index 20be9be55ca..1eedc7e1261 100644 --- a/composer.json +++ b/composer.json @@ -104,6 +104,7 @@ "symfony/expression-language": "To use authorization features.", "symfony/security": "To use authorization features.", "symfony/twig-bundle": "To use the Swagger UI integration.", + "symfony/uid": "To support Symfony UUID/ULID identifiers.", "symfony/web-profiler-bundle": "To use the data collector.", "webonyx/graphql-php": "To support GraphQL." }, diff --git a/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index 4a33e424ae3..fef779fe01c 100644 --- a/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -53,6 +53,7 @@ use Symfony\Component\Finder\Finder; use Symfony\Component\HttpClient\HttpClientTrait; use Symfony\Component\HttpKernel\DependencyInjection\Extension; +use Symfony\Component\Uid\AbstractUid; use Symfony\Component\Validator\Validator\ValidatorInterface; use Symfony\Component\Yaml\Yaml; @@ -167,6 +168,10 @@ private function registerCommonConfiguration(ContainerBuilder $container, array $loader->load('ramsey_uuid.xml'); } + if (class_exists(AbstractUid::class)) { + $loader->load('symfony_uid.xml'); + } + $container->setParameter('api_platform.enable_entrypoint', $config['enable_entrypoint']); $container->setParameter('api_platform.enable_docs', $config['enable_docs']); $container->setParameter('api_platform.title', $config['title']); diff --git a/src/Bridge/Symfony/Bundle/Resources/config/symfony_uid.xml b/src/Bridge/Symfony/Bundle/Resources/config/symfony_uid.xml new file mode 100644 index 00000000000..6dbc7a8ff06 --- /dev/null +++ b/src/Bridge/Symfony/Bundle/Resources/config/symfony_uid.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + diff --git a/src/Bridge/Symfony/Identifier/Normalizer/UlidNormalizer.php b/src/Bridge/Symfony/Identifier/Normalizer/UlidNormalizer.php new file mode 100644 index 00000000000..fbfd6387b2d --- /dev/null +++ b/src/Bridge/Symfony/Identifier/Normalizer/UlidNormalizer.php @@ -0,0 +1,44 @@ + + * + * 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\Bridge\Symfony\Identifier\Normalizer; + +use ApiPlatform\Core\Exception\InvalidIdentifierException; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; +use Symfony\Component\Uid\Ulid; + +/** + * Denormalizes an ULID string to an instance of Symfony\Component\Uid\Ulid. + */ +final class UlidNormalizer implements DenormalizerInterface +{ + /** + * {@inheritdoc} + */ + public function denormalize($data, $class, $format = null, array $context = []) + { + try { + return Ulid::fromString($data); + } catch (\InvalidArgumentException $e) { + throw new InvalidIdentifierException($e->getMessage(), $e->getCode(), $e); + } + } + + /** + * {@inheritdoc} + */ + public function supportsDenormalization($data, $type, $format = null) + { + return \is_string($data) && is_a($type, Ulid::class, true); + } +} diff --git a/src/Bridge/Symfony/Identifier/Normalizer/UuidNormalizer.php b/src/Bridge/Symfony/Identifier/Normalizer/UuidNormalizer.php new file mode 100644 index 00000000000..7123181c387 --- /dev/null +++ b/src/Bridge/Symfony/Identifier/Normalizer/UuidNormalizer.php @@ -0,0 +1,44 @@ + + * + * 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\Bridge\Symfony\Identifier\Normalizer; + +use ApiPlatform\Core\Exception\InvalidIdentifierException; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; +use Symfony\Component\Uid\Uuid; + +/** + * Denormalizes an UUID string to an instance of Symfony\Component\Uid\Uuid. + */ +final class UuidNormalizer implements DenormalizerInterface +{ + /** + * {@inheritdoc} + */ + public function denormalize($data, $class, $format = null, array $context = []) + { + try { + return Uuid::fromString($data); + } catch (\InvalidArgumentException $e) { + throw new InvalidIdentifierException($e->getMessage(), $e->getCode(), $e); + } + } + + /** + * {@inheritdoc} + */ + public function supportsDenormalization($data, $type, $format = null) + { + return \is_string($data) && is_a($type, Uuid::class, true); + } +} diff --git a/src/JsonSchema/TypeFactory.php b/src/JsonSchema/TypeFactory.php index f3d3f48a63e..b54db25e998 100644 --- a/src/JsonSchema/TypeFactory.php +++ b/src/JsonSchema/TypeFactory.php @@ -17,6 +17,8 @@ use ApiPlatform\Core\Util\ResourceClassInfoTrait; use Ramsey\Uuid\UuidInterface; use Symfony\Component\PropertyInfo\Type; +use Symfony\Component\Uid\Ulid; +use Symfony\Component\Uid\Uuid; /** * {@inheritdoc} @@ -106,12 +108,18 @@ private function getClassType(?string $className, string $format, ?bool $readabl 'format' => 'duration', ]; } - if (is_a($className, UuidInterface::class, true)) { + if (is_a($className, UuidInterface::class, true) || is_a($className, Uuid::class, true)) { return [ 'type' => 'string', 'format' => 'uuid', ]; } + if (is_a($className, Ulid::class, true)) { + return [ + 'type' => 'string', + 'format' => 'ulid', + ]; + } // Skip if $schema is null (filters only support basic types) if (null === $schema) { diff --git a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php index 56439397649..60362ccfd0f 100644 --- a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php +++ b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php @@ -113,6 +113,7 @@ use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Serializer\Exception\ExceptionInterface; +use Symfony\Component\Uid\AbstractUid; /** * @group resource-hog @@ -986,6 +987,11 @@ private function getPartialContainerBuilderProphecy($configuration = null) 'api_platform.subresource_operation_factory.cached', ]; + if (class_exists(AbstractUid::class)) { + $definitions[] = 'api_platform.identifier.symfony_ulid_normalizer'; + $definitions[] = 'api_platform.identifier.symfony_uuid_normalizer'; + } + foreach ($definitions as $definition) { $containerBuilderProphecy->setDefinition($definition, Argument::type(Definition::class))->shouldBeCalled(); } diff --git a/tests/Bridge/Symfony/Identifier/Normalizer/UlidNormalizerTest.php b/tests/Bridge/Symfony/Identifier/Normalizer/UlidNormalizerTest.php new file mode 100644 index 00000000000..ca716a07b9f --- /dev/null +++ b/tests/Bridge/Symfony/Identifier/Normalizer/UlidNormalizerTest.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\Tests\Bridge\Symfony\Identifier\Normalizer; + +use ApiPlatform\Core\Bridge\Symfony\Identifier\Normalizer\UlidNormalizer; +use ApiPlatform\Core\Exception\InvalidIdentifierException; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Uid\AbstractUid; +use Symfony\Component\Uid\Ulid; + +final class UlidNormalizerTest extends TestCase +{ + protected function setUp(): void + { + if (!class_exists(AbstractUid::class)) { + $this->markTestSkipped(); + } + } + + public function testDenormalizeUlid() + { + $ulid = new Ulid(); + $normalizer = new UlidNormalizer(); + $this->assertTrue($normalizer->supportsDenormalization($ulid->__toString(), Ulid::class)); + $this->assertEquals($ulid, $normalizer->denormalize($ulid->__toString(), Ulid::class)); + } + + public function testNoSupportDenormalizeUlid() + { + $ulid = 'notanulid'; + $normalizer = new UlidNormalizer(); + $this->assertFalse($normalizer->supportsDenormalization($ulid, '')); + } + + public function testFailDenormalizeUlid() + { + $this->expectException(InvalidIdentifierException::class); + + $ulid = 'notanulid'; + $normalizer = new UlidNormalizer(); + $this->assertTrue($normalizer->supportsDenormalization($ulid, Ulid::class)); + $normalizer->denormalize($ulid, Ulid::class); + } + + public function testDoNotSupportNotString() + { + $ulid = new Ulid(); + $normalizer = new UlidNormalizer(); + $this->assertFalse($normalizer->supportsDenormalization($ulid, Ulid::class)); + } +} diff --git a/tests/Bridge/Symfony/Identifier/Normalizer/UuidNormalizerTest.php b/tests/Bridge/Symfony/Identifier/Normalizer/UuidNormalizerTest.php new file mode 100644 index 00000000000..aff588ae090 --- /dev/null +++ b/tests/Bridge/Symfony/Identifier/Normalizer/UuidNormalizerTest.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\Tests\Bridge\Symfony\Identifier\Normalizer; + +use ApiPlatform\Core\Bridge\Symfony\Identifier\Normalizer\UuidNormalizer; +use ApiPlatform\Core\Exception\InvalidIdentifierException; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Uid\AbstractUid; +use Symfony\Component\Uid\Uuid; + +final class UuidNormalizerTest extends TestCase +{ + protected function setUp(): void + { + if (!class_exists(AbstractUid::class)) { + $this->markTestSkipped(); + } + } + + public function testDenormalizeUuid() + { + $uuid = Uuid::v4(); + $normalizer = new UuidNormalizer(); + $this->assertTrue($normalizer->supportsDenormalization($uuid->__toString(), Uuid::class)); + $this->assertEquals($uuid, $normalizer->denormalize($uuid->__toString(), Uuid::class)); + } + + public function testNoSupportDenormalizeUuid() + { + $uuid = 'notanuuid'; + $normalizer = new UuidNormalizer(); + $this->assertFalse($normalizer->supportsDenormalization($uuid, '')); + } + + public function testFailDenormalizeUuid() + { + $this->expectException(InvalidIdentifierException::class); + + $uuid = 'notanuuid'; + $normalizer = new UuidNormalizer(); + $this->assertTrue($normalizer->supportsDenormalization($uuid, Uuid::class)); + $normalizer->denormalize($uuid, Uuid::class); + } + + public function testDoNotSupportNotString() + { + $uuid = Uuid::v4(); + $normalizer = new UuidNormalizer(); + $this->assertFalse($normalizer->supportsDenormalization($uuid, Uuid::class)); + } +} From 52206c3512d4f990590dd7dd1067463df48134e3 Mon Sep 17 00:00:00 2001 From: Damien Fayet Date: Thu, 10 Sep 2020 21:02:17 +0200 Subject: [PATCH 111/160] Add Api-Platform's version in debug bar (#3235) * Add Api-Platform's version in debug bar * Move version in hover tooltip * fix: unit test Co-authored-by: Damien Co-authored-by: Alan Poulain --- CHANGELOG.md | 1 + composer.json | 1 + .../DataCollector/RequestDataCollector.php | 13 +++++++++++ .../views/DataCollector/request.html.twig | 7 +++++- .../RequestDataCollectorTest.php | 22 +++++++++++++++++++ .../Twig/ApiPlatformProfilerPanelTest.php | 4 ++-- 6 files changed, 45 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d5d2fccd95c..adf29598ee1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## 2.6.0 +* Display the API Platform's version in the debug-bar * MongoDB: Possibility to add execute options (aggregate command fields) for a resource, like `allowDiskUse` (#3144) * MongoDB: Mercure support (#3290) * GraphQL: Subscription support with Mercure (#3321) diff --git a/composer.json b/composer.json index 1eedc7e1261..03fa8a2b186 100644 --- a/composer.json +++ b/composer.json @@ -96,6 +96,7 @@ "doctrine/mongodb-odm-bundle": "To support MongoDB. Only versions 4.0 and later are supported.", "elasticsearch/elasticsearch": "To support Elasticsearch.", "guzzlehttp/guzzle": "To use the HTTP cache invalidation system.", + "ocramius/package-versions": "To display the API Platform's version in the debug bar.", "phpdocumentor/reflection-docblock": "To support extracting metadata from PHPDoc.", "psr/cache-implementation": "To use metadata caching.", "ramsey/uuid": "To support Ramsey's UUID identifiers.", diff --git a/src/Bridge/Symfony/Bundle/DataCollector/RequestDataCollector.php b/src/Bridge/Symfony/Bundle/DataCollector/RequestDataCollector.php index 9ecf4c46c30..a5c8db17701 100644 --- a/src/Bridge/Symfony/Bundle/DataCollector/RequestDataCollector.php +++ b/src/Bridge/Symfony/Bundle/DataCollector/RequestDataCollector.php @@ -23,6 +23,7 @@ use ApiPlatform\Core\DataProvider\SubresourceDataProviderInterface; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use ApiPlatform\Core\Util\RequestAttributesExtractor; +use PackageVersions\Versions; use Psr\Container\ContainerInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -158,6 +159,18 @@ public function getDataPersisters(): array return $this->data['dataPersisters'] ?? ['responses' => []]; } + public function getVersion(): ?string + { + if (!class_exists(Versions::class)) { + return null; + } + + $version = Versions::getVersion('api-platform/core'); + preg_match('/^v(.*?)@/', $version, $output); + + return $output[1] ?? strtok($version, '@'); + } + /** * {@inheritdoc} */ diff --git a/src/Bridge/Symfony/Bundle/Resources/views/DataCollector/request.html.twig b/src/Bridge/Symfony/Bundle/Resources/views/DataCollector/request.html.twig index 79d78a25bf2..13bfe8939e2 100644 --- a/src/Bridge/Symfony/Bundle/Resources/views/DataCollector/request.html.twig +++ b/src/Bridge/Symfony/Bundle/Resources/views/DataCollector/request.html.twig @@ -80,10 +80,15 @@ {% set icon %} {% set status_color = collector.counters.ignored_filters|default(false) ? 'yellow' : 'default' %} {{ include('@ApiPlatform/DataCollector/api-platform-icon.svg') }} - {% endset %} {% set text %} + {% if collector.version %} +
+ Version + {{ collector.version }} +
+ {% endif %}
Resource Class {{ collector.resourceClass|default('Not an API Platform resource') }} diff --git a/tests/Bridge/Symfony/Bundle/DataCollector/RequestDataCollectorTest.php b/tests/Bridge/Symfony/Bundle/DataCollector/RequestDataCollectorTest.php index 7621d124ef2..52e482ff848 100644 --- a/tests/Bridge/Symfony/Bundle/DataCollector/RequestDataCollectorTest.php +++ b/tests/Bridge/Symfony/Bundle/DataCollector/RequestDataCollectorTest.php @@ -30,6 +30,7 @@ use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; use ApiPlatform\Core\Tests\Fixtures\DummyEntity; use ApiPlatform\Core\Tests\ProphecyTrait; +use PackageVersions\Versions; use PHPUnit\Framework\TestCase; use Psr\Container\ContainerInterface; use Symfony\Component\HttpFoundation\ParameterBag; @@ -212,6 +213,27 @@ public function testWithResourceWithTraceables() } } + public function testVersionCollection() + { + $this->apiResourceClassWillReturn(DummyEntity::class); + + $dataCollector = new RequestDataCollector( + $this->metadataFactory->reveal(), + $this->filterLocator->reveal(), + $this->getUsedCollectionDataProvider(), + $this->getUsedItemDataProvider(), + $this->getUsedSubresourceDataProvider(), + $this->getUsedPersister() + ); + + $dataCollector->collect( + $this->request->reveal(), + $this->response + ); + + $this->assertSame(null !== $dataCollector->getVersion(), class_exists(Versions::class)); + } + private function apiResourceClassWillReturn($data, $context = []) { $this->attributes->get('_api_resource_class')->shouldBeCalled()->willReturn($data); diff --git a/tests/Bridge/Symfony/Bundle/Twig/ApiPlatformProfilerPanelTest.php b/tests/Bridge/Symfony/Bundle/Twig/ApiPlatformProfilerPanelTest.php index 9ff6806e6d9..3ea58ad0f04 100644 --- a/tests/Bridge/Symfony/Bundle/Twig/ApiPlatformProfilerPanelTest.php +++ b/tests/Bridge/Symfony/Bundle/Twig/ApiPlatformProfilerPanelTest.php @@ -81,7 +81,7 @@ public function testDebugBarContentNotResourceClass() // Check extra info content $this->assertStringContainsString('sf-toolbar-status-default', $block->attr('class'), 'The toolbar block should have the default color.'); - $this->assertSame('Not an API Platform resource', $block->filter('.sf-toolbar-info-piece span')->html()); + $this->assertSame('Not an API Platform resource', $block->filterXPath('//div[@class="sf-toolbar-info-piece"][./b[contains(., "Resource Class")]]/span')->html()); } public function testDebugBarContent() @@ -99,7 +99,7 @@ public function testDebugBarContent() // Check extra info content $this->assertStringContainsString('sf-toolbar-status-default', $block->attr('class'), 'The toolbar block should have the default color.'); - $this->assertSame('mongodb' === $this->env ? DocumentDummy::class : Dummy::class, $block->filter('.sf-toolbar-info-piece span')->html()); + $this->assertSame('mongodb' === $this->env ? DocumentDummy::class : Dummy::class, $block->filterXPath('//div[@class="sf-toolbar-info-piece"][./b[contains(., "Resource Class")]]/span')->html()); } public function testProfilerGeneralLayoutNotResourceClass() From 278a1cba0e23e496d3cb704e84a23c7b03ca5843 Mon Sep 17 00:00:00 2001 From: soyuka Date: Sun, 13 Sep 2020 15:06:02 +0200 Subject: [PATCH 112/160] fix windows test --- tests/Bridge/Symfony/Bundle/Command/OpenApiCommandTest.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/Bridge/Symfony/Bundle/Command/OpenApiCommandTest.php b/tests/Bridge/Symfony/Bundle/Command/OpenApiCommandTest.php index a9df5225a0e..a6767e8e786 100644 --- a/tests/Bridge/Symfony/Bundle/Command/OpenApiCommandTest.php +++ b/tests/Bridge/Symfony/Bundle/Command/OpenApiCommandTest.php @@ -63,7 +63,7 @@ public function testExecuteWithYaml() - DummyCar YAML; - $this->assertStringContainsString($expected, $result, 'nested object should be present.'); + $this->assertStringContainsString(str_replace(PHP_EOL, "\n", $expected), $result, 'nested object should be present.'); $expected = <<assertStringContainsString($expected, $result, 'arrays should be correctly formatted.'); + $this->assertStringContainsString(str_replace(PHP_EOL, "\n", $expected), $result, 'arrays should be correctly formatted.'); $this->assertStringContainsString('openapi: '.OpenApi::VERSION, $result); $expected = <<assertStringContainsString($expected, $result, 'multiline formatting must be preserved (using literal style).'); + $this->assertStringContainsString(str_replace(PHP_EOL, "\n", $expected), $result, 'multiline formatting must be preserved (using literal style).'); } public function testWriteToFile() From 6e2538b81a3ecf857f6660b5068c88dcfb2c0aca Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Sun, 13 Sep 2020 19:37:25 +0200 Subject: [PATCH 113/160] Pre hydrate input before denormalize (#3701) * Add possibility to hydrate input before denormalize * Fix @soyuka review * Add test * Remove useless set context * Add deep object to populate to avoid missing data * change name to inputClass to be more explicit * Fix test * Rename PreHydrateInput to DataTransformerInitializer * Add behat test data transformer initializer * fix tests Co-authored-by: Alexandre Vinet --- features/bootstrap/DoctrineContext.php | 24 +++++++ features/jsonld/input_output.feature | 23 +++++++ .../ApiPlatformExtension.php | 4 ++ .../DataTransformerInitializerInterface.php | 26 ++++++++ src/Serializer/AbstractItemNormalizer.php | 5 ++ .../ApiPlatformExtensionTest.php | 7 ++- .../InitializeInputDataTransformer.php | 62 +++++++++++++++++++ .../TestBundle/Document/InitializeInput.php | 40 ++++++++++++ .../TestBundle/Dto/InitializeInputDto.php | 27 ++++++++ .../TestBundle/Entity/InitializeInput.php | 41 ++++++++++++ tests/Fixtures/app/config/config_common.yml | 6 ++ .../Serializer/AbstractItemNormalizerTest.php | 16 ++++- 12 files changed, 277 insertions(+), 4 deletions(-) create mode 100644 src/DataTransformer/DataTransformerInitializerInterface.php create mode 100644 tests/Fixtures/TestBundle/DataTransformer/InitializeInputDataTransformer.php create mode 100644 tests/Fixtures/TestBundle/Document/InitializeInput.php create mode 100644 tests/Fixtures/TestBundle/Dto/InitializeInputDto.php create mode 100644 tests/Fixtures/TestBundle/Entity/InitializeInput.php diff --git a/features/bootstrap/DoctrineContext.php b/features/bootstrap/DoctrineContext.php index 3901c90bc66..c6c4ba87e9e 100644 --- a/features/bootstrap/DoctrineContext.php +++ b/features/bootstrap/DoctrineContext.php @@ -53,6 +53,7 @@ use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\FooDummy as FooDummyDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\FourthLevel as FourthLevelDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\Greeting as GreetingDocument; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\InitializeInput as InitializeInputDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\MaxDepthDummy as MaxDepthDummyDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\NetworkPathDummy as NetworkPathDummyDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\NetworkPathRelationDummy as NetworkPathRelationDummyDocument; @@ -117,6 +118,7 @@ use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\FooDummy; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\FourthLevel; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Greeting; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\InitializeInput; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\InternalUser; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\MaxDepthDummy; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\NetworkPathDummy; @@ -1589,6 +1591,20 @@ public function thereAreNetworkPathDummies(int $nb) $this->manager->flush(); } + /** + * @Given there is an InitializeInput object with id :id + */ + public function thereIsAnInitializeInput(int $id) + { + $initializeInput = $this->buildInitializeInput(); + $initializeInput->id = $id; + $initializeInput->manager = 'Orwell'; + $initializeInput->name = '1984'; + + $this->manager->persist($initializeInput); + $this->manager->flush(); + } + private function isOrm(): bool { return null !== $this->schemaTool; @@ -2006,4 +2022,12 @@ private function buildNetworkPathRelationDummy() { return $this->isOrm() ? new NetworkPathRelationDummy() : new NetworkPathRelationDummyDocument(); } + + /** + * @return InitializeInput|InitializeInputDocument + */ + private function buildInitializeInput() + { + return $this->isOrm() ? new InitializeInput() : new InitializeInputDocument(); + } } diff --git a/features/jsonld/input_output.feature b/features/jsonld/input_output.feature index a4be765cead..b476252fafe 100644 --- a/features/jsonld/input_output.feature +++ b/features/jsonld/input_output.feature @@ -317,3 +317,26 @@ Feature: JSON-LD DTO input and output "data": 123 } """ + + @createSchema + Scenario: Initialize input data with a DataTransformerInitializer + Given there is an InitializeInput object with id 1 + When I send a "PUT" request to "/initialize_inputs/1" with body: + """ + { + "name": "La peste" + } + """ + Then the response status code should be 200 + And the response should be in JSON + And the JSON should be equal to: + """ + { + "@context": "/contexts/InitializeInput", + "@id": "/initialize_inputs/1", + "@type": "InitializeInput", + "id": 1, + "manager": "Orwell", + "name": "La peste" + } + """ diff --git a/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index fef779fe01c..550c29a6fab 100644 --- a/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -30,6 +30,7 @@ use ApiPlatform\Core\DataProvider\CollectionDataProviderInterface; use ApiPlatform\Core\DataProvider\ItemDataProviderInterface; use ApiPlatform\Core\DataProvider\SubresourceDataProviderInterface; +use ApiPlatform\Core\DataTransformer\DataTransformerInitializerInterface; use ApiPlatform\Core\DataTransformer\DataTransformerInterface; use ApiPlatform\Core\GraphQl\Error\ErrorHandlerInterface; use ApiPlatform\Core\GraphQl\Resolver\MutationResolverInterface; @@ -688,6 +689,9 @@ private function registerDataTransformerConfiguration(ContainerBuilder $containe { $container->registerForAutoconfiguration(DataTransformerInterface::class) ->addTag('api_platform.data_transformer'); + + $container->registerForAutoconfiguration(DataTransformerInitializerInterface::class) + ->addTag('api_platform.data_transformer'); } private function registerSecurityConfiguration(ContainerBuilder $container, XmlFileLoader $loader): void diff --git a/src/DataTransformer/DataTransformerInitializerInterface.php b/src/DataTransformer/DataTransformerInitializerInterface.php new file mode 100644 index 00000000000..85007c818e1 --- /dev/null +++ b/src/DataTransformer/DataTransformerInitializerInterface.php @@ -0,0 +1,26 @@ + + * + * 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\DataTransformer; + +interface DataTransformerInitializerInterface extends DataTransformerInterface +{ + /** + * Creates a new DTO object that the data will then be serialized into (using object_to_populate). + * + * This is useful to "initialize" the DTO object based on the current resource's data. + * + * @return object|null + */ + public function initialize(string $inputClass, array $context = []); +} diff --git a/src/Serializer/AbstractItemNormalizer.php b/src/Serializer/AbstractItemNormalizer.php index 6a75419b13f..7fa39d3d4ee 100644 --- a/src/Serializer/AbstractItemNormalizer.php +++ b/src/Serializer/AbstractItemNormalizer.php @@ -16,6 +16,7 @@ use ApiPlatform\Core\Api\IriConverterInterface; use ApiPlatform\Core\Api\ResourceClassResolverInterface; use ApiPlatform\Core\DataProvider\ItemDataProviderInterface; +use ApiPlatform\Core\DataTransformer\DataTransformerInitializerInterface; use ApiPlatform\Core\DataTransformer\DataTransformerInterface; use ApiPlatform\Core\Exception\InvalidArgumentException; use ApiPlatform\Core\Exception\InvalidValueException; @@ -197,6 +198,10 @@ public function denormalize($data, $class, $format = null, array $context = []) if (!$this->serializer instanceof DenormalizerInterface) { throw new LogicException('Cannot denormalize the input because the injected serializer is not a denormalizer'); } + if ($dataTransformer instanceof DataTransformerInitializerInterface) { + $context[AbstractObjectNormalizer::OBJECT_TO_POPULATE] = $dataTransformer->initialize($inputClass, $context); + $context[AbstractObjectNormalizer::DEEP_OBJECT_TO_POPULATE] = true; + } $denormalizedInput = $this->serializer->denormalize($data, $inputClass, $format, $context); if (!\is_object($denormalizedInput)) { throw new \UnexpectedValueException('Expected denormalized input to be an object.'); diff --git a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php index 60362ccfd0f..838558f901c 100644 --- a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php +++ b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php @@ -63,6 +63,7 @@ use ApiPlatform\Core\DataProvider\Pagination; use ApiPlatform\Core\DataProvider\PaginationOptions; use ApiPlatform\Core\DataProvider\SubresourceDataProviderInterface; +use ApiPlatform\Core\DataTransformer\DataTransformerInitializerInterface; use ApiPlatform\Core\DataTransformer\DataTransformerInterface; use ApiPlatform\Core\Exception\FilterValidationException; use ApiPlatform\Core\Exception\InvalidArgumentException; @@ -1145,7 +1146,11 @@ private function getBaseContainerBuilderProphecy(array $doctrineIntegrationsToLo $containerBuilderProphecy->registerForAutoconfiguration(DataTransformerInterface::class) ->willReturn($this->childDefinitionProphecy)->shouldBeCalledTimes(1); - $this->childDefinitionProphecy->addTag('api_platform.data_transformer')->shouldBeCalledTimes(1); + + $containerBuilderProphecy->registerForAutoconfiguration(DataTransformerInitializerInterface::class) + ->willReturn($this->childDefinitionProphecy)->shouldBeCalledTimes(1); + + $this->childDefinitionProphecy->addTag('api_platform.data_transformer')->shouldBeCalledTimes(2); $containerBuilderProphecy->addResource(Argument::type(DirectoryResource::class))->shouldBeCalled(); diff --git a/tests/Fixtures/TestBundle/DataTransformer/InitializeInputDataTransformer.php b/tests/Fixtures/TestBundle/DataTransformer/InitializeInputDataTransformer.php new file mode 100644 index 00000000000..9f151b0a0c5 --- /dev/null +++ b/tests/Fixtures/TestBundle/DataTransformer/InitializeInputDataTransformer.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\Tests\Fixtures\TestBundle\DataTransformer; + +use ApiPlatform\Core\DataTransformer\DataTransformerInitializerInterface; +use ApiPlatform\Core\Serializer\AbstractItemNormalizer; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\InitializeInput as InitializeInputDocument; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Dto\InitializeInputDto; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\InitializeInput; + +final class InitializeInputDataTransformer implements DataTransformerInitializerInterface +{ + /** + * {@inheritdoc} + */ + public function transform($object, string $to, array $context = []) + { + /** @var InitializeInputDto */ + $data = $object; + + /** @var InitializeInput|InitializeInputDocument */ + $resourceObject = $context[AbstractItemNormalizer::OBJECT_TO_POPULATE] ?? new $context['resource_class'](); + $resourceObject->name = $data->name; + + return $resourceObject; + } + + /** + * {@inheritdoc} + */ + public function initialize(string $inputClass, array $context = []) + { + $currentResource = $context[AbstractItemNormalizer::OBJECT_TO_POPULATE] ?? null; + if (!$currentResource) { + return new InitializeInputDto(); + } + + $dto = new InitializeInputDto(); + $dto->manager = $currentResource->manager; + + return $dto; + } + + /** + * {@inheritdoc} + */ + public function supportsTransformation($data, string $to, array $context = []): bool + { + return (InitializeInput::class === $to || InitializeInputDocument::class === $to) && InitializeInputDto::class === ($context['input']['class'] ?? null); + } +} diff --git a/tests/Fixtures/TestBundle/Document/InitializeInput.php b/tests/Fixtures/TestBundle/Document/InitializeInput.php new file mode 100644 index 00000000000..3171097a946 --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/InitializeInput.php @@ -0,0 +1,40 @@ + + * + * 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\Tests\Fixtures\TestBundle\Document; + +use ApiPlatform\Core\Annotation\ApiResource; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Dto\InitializeInputDto; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +/** + * @ApiResource(input=InitializeInputDto::class) + * @ODM\Document + */ +class InitializeInput +{ + /** + * @ODM\Id(strategy="NONE", type="integer") + */ + public $id; + + /** + * @ODM\Field + */ + public $manager; + + /** + * @ODM\Field + */ + public $name; +} diff --git a/tests/Fixtures/TestBundle/Dto/InitializeInputDto.php b/tests/Fixtures/TestBundle/Dto/InitializeInputDto.php new file mode 100644 index 00000000000..a7fa3c8dc94 --- /dev/null +++ b/tests/Fixtures/TestBundle/Dto/InitializeInputDto.php @@ -0,0 +1,27 @@ + + * + * 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\Tests\Fixtures\TestBundle\Dto; + +class InitializeInputDto +{ + /** + * @var string + */ + public $name; + + /** + * @var string + */ + public $manager; +} diff --git a/tests/Fixtures/TestBundle/Entity/InitializeInput.php b/tests/Fixtures/TestBundle/Entity/InitializeInput.php new file mode 100644 index 00000000000..07e77f7d486 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/InitializeInput.php @@ -0,0 +1,41 @@ + + * + * 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\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Core\Annotation\ApiResource; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Dto\InitializeInputDto; +use Doctrine\ORM\Mapping as ORM; + +/** + * @ApiResource(input=InitializeInputDto::class) + * @ORM\Entity + */ +class InitializeInput +{ + /** + * @ORM\Id + * @ORM\Column(type="integer") + */ + public $id; + + /** + * @ORM\Column + */ + public $manager; + + /** + * @ORM\Column + */ + public $name; +} diff --git a/tests/Fixtures/app/config/config_common.yml b/tests/Fixtures/app/config/config_common.yml index 5736d354ac9..68b5f801f52 100644 --- a/tests/Fixtures/app/config/config_common.yml +++ b/tests/Fixtures/app/config/config_common.yml @@ -291,6 +291,12 @@ services: tags: - { name: 'api_platform.data_transformer' } + app.data_transformer.initialize_input: + class: 'ApiPlatform\Core\Tests\Fixtures\TestBundle\DataTransformer\InitializeInputDataTransformer' + public: false + tags: + - { name: 'api_platform.data_transformer' } + app.messenger_handler.messenger_with_response: class: 'ApiPlatform\Core\Tests\Fixtures\TestBundle\MessengerHandler\MessengerWithResponseHandler' public: false diff --git a/tests/Serializer/AbstractItemNormalizerTest.php b/tests/Serializer/AbstractItemNormalizerTest.php index 23f2f1603c3..7f81ac608df 100644 --- a/tests/Serializer/AbstractItemNormalizerTest.php +++ b/tests/Serializer/AbstractItemNormalizerTest.php @@ -16,6 +16,7 @@ use ApiPlatform\Core\Api\IriConverterInterface; use ApiPlatform\Core\Api\ResourceClassResolverInterface; use ApiPlatform\Core\DataProvider\ItemDataProviderInterface; +use ApiPlatform\Core\DataTransformer\DataTransformerInitializerInterface; use ApiPlatform\Core\DataTransformer\DataTransformerInterface; use ApiPlatform\Core\Exception\InvalidArgumentException; use ApiPlatform\Core\Exception\ItemNotFoundException; @@ -44,6 +45,7 @@ use Symfony\Component\PropertyInfo\Type; use Symfony\Component\Serializer\Exception\UnexpectedValueException; use Symfony\Component\Serializer\NameConverter\AdvancedNameConverterInterface; +use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer; use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; use Symfony\Component\Serializer\SerializerInterface; @@ -404,13 +406,19 @@ public function testCanDenormalizeInputClassWithDifferentFieldsThanResourceClass 'output' => ['class' => DummyForAdditionalFields::class], ]; $augmentedContext = $context + ['api_denormalize' => true]; + + $preHydratedDummy = new DummyForAdditionalFieldsInput('Name Dummy'); $cleanedContext = array_diff_key($augmentedContext, [ 'input' => null, 'resource_class' => null, ]); + $cleanedContextWithObjectToPopulate = array_merge($cleanedContext, [ + AbstractObjectNormalizer::OBJECT_TO_POPULATE => $preHydratedDummy, + AbstractObjectNormalizer::DEEP_OBJECT_TO_POPULATE => true, + ]); $dummyInputDto = new DummyForAdditionalFieldsInput('Dummy Name'); - $dummy = new DummyForAdditionalFields('Dummy Name', 'dummy-name'); + $dummy = new DummyForAdditionalFields('Dummy Name', 'name-dummy'); $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); @@ -421,13 +429,15 @@ public function testCanDenormalizeInputClassWithDifferentFieldsThanResourceClass $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); $resourceClassResolverProphecy->getResourceClass(null, DummyForAdditionalFields::class)->willReturn(DummyForAdditionalFields::class); - $inputDataTransformerProphecy = $this->prophesize(DataTransformerInterface::class); + $inputDataTransformerProphecy = $this->prophesize(DataTransformerInitializerInterface::class); + $inputDataTransformerProphecy->willImplement(DataTransformerInitializerInterface::class); + $inputDataTransformerProphecy->initialize(DummyForAdditionalFieldsInput::class, $cleanedContext)->willReturn($preHydratedDummy); $inputDataTransformerProphecy->supportsTransformation($data, DummyForAdditionalFields::class, $augmentedContext)->willReturn(true); $inputDataTransformerProphecy->transform($dummyInputDto, DummyForAdditionalFields::class, $augmentedContext)->willReturn($dummy); $serializerProphecy = $this->prophesize(SerializerInterface::class); $serializerProphecy->willImplement(DenormalizerInterface::class); - $serializerProphecy->denormalize($data, DummyForAdditionalFieldsInput::class, 'json', $cleanedContext)->willReturn($dummyInputDto); + $serializerProphecy->denormalize($data, DummyForAdditionalFieldsInput::class, 'json', $cleanedContextWithObjectToPopulate)->willReturn($dummyInputDto); $normalizer = new class($propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), $iriConverterProphecy->reveal(), $resourceClassResolverProphecy->reveal(), null, null, null, null, false, [], [$inputDataTransformerProphecy->reveal()], null, null) extends AbstractItemNormalizer { }; From cb3f641f68cb1c27309dadc1b27330f96042daa1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Dunglas?= Date: Mon, 14 Sep 2020 14:29:25 +0200 Subject: [PATCH 114/160] Add Tidelift to FUNDING.yml --- .github/FUNDING.yml | 1 + 1 file changed, 1 insertion(+) create mode 100644 .github/FUNDING.yml diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 00000000000..99822f99fc1 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +tidelift: "packagist/api-platform/core" From 3c8034557bd925202c23eee8084d747013ad92fe Mon Sep 17 00:00:00 2001 From: Mathias Arlaud Date: Thu, 27 Feb 2020 22:41:46 +0100 Subject: [PATCH 115/160] Handle Symfony stateless control --- CHANGELOG.md | 1 + src/Annotation/ApiResource.php | 9 +++ .../ApiPlatformExtension.php | 5 ++ src/Bridge/Symfony/Routing/ApiLoader.php | 2 + .../OperationResourceMetadataFactory.php | 10 +-- .../Factory/SubresourceOperationFactory.php | 2 +- .../ApiPlatformExtensionTest.php | 7 ++- .../Bridge/Symfony/Routing/ApiLoaderTest.php | 61 +++++++++++-------- .../Fixtures/FileConfigurations/resources.xml | 1 + .../Fixtures/FileConfigurations/resources.yml | 1 + .../FileConfigurations/single_resource.yml | 2 + tests/Fixtures/app/config/config_common.yml | 1 + .../AnnotationResourceMetadataFactoryTest.php | 5 +- .../ExtractorResourceMetadataFactoryTest.php | 3 + ...leConfigurationMetadataFactoryProvider.php | 1 + .../OperationResourceMetadataFactoryTest.php | 20 +++--- 16 files changed, 88 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index adf29598ee1..ef9e6acc205 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ * Subresources: subresource resourceClass can now be defined as a container parameter in XML and Yaml definitions * IriConverter: Fix IRI url double encoding - may cause breaking change as some characters no longer encoded in output (#3552) * OpenAPI: **BC** Replace all characters other than `[a-zA-Z0-9\.\-_]` to `.` in definition names to be compliant with OpenAPI 3.0 (#3669) +* Add stateless ApiResource attribute ## 2.5.7 diff --git a/src/Annotation/ApiResource.php b/src/Annotation/ApiResource.php index 1a89a559ec7..831fd76f0cd 100644 --- a/src/Annotation/ApiResource.php +++ b/src/Annotation/ApiResource.php @@ -63,6 +63,7 @@ * @Attribute("securityPostDenormalize", type="string"), * @Attribute("securityPostDenormalizeMessage", type="string"), * @Attribute("shortName", type="string"), + * @Attribute("stateless", type="bool"), * @Attribute("subresourceOperations", type="array"), * @Attribute("sunset", type="string"), * @Attribute("swaggerContext", type="array"), @@ -118,6 +119,7 @@ final class ApiResource 'paginationPartial', 'paginationViaCursor', 'routePrefix', + 'stateless', 'sunset', 'swaggerContext', 'urlGenerationStrategy', @@ -414,6 +416,13 @@ final class ApiResource */ private $securityPostDenormalizeMessage; + /** + * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 + * + * @var bool + */ + private $stateless; + /** * @see https://api-platform.com/docs/core/deprecations/#setting-the-sunset-http-header-to-indicate-when-a-resource-or-an-operation-will-be-removed * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 diff --git a/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index 550c29a6fab..b7bf18a94d1 100644 --- a/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -257,6 +257,11 @@ private function normalizeDefaults(array $defaults): array } } + if (!\array_key_exists('stateless', $defaults)) { + @trigger_error('Not setting the "api_platform.defaults.stateless" configuration is deprecated since API Platform 2.6 and it will default to `true` in 3.0. You can override this at the operation level if you have stateful operations (highly not recommended).', E_USER_DEPRECATED); + $normalizedDefaults['attributes']['stateless'] = false; + } + return $normalizedDefaults; } diff --git a/src/Bridge/Symfony/Routing/ApiLoader.php b/src/Bridge/Symfony/Routing/ApiLoader.php index aaa093a1cfb..3bdad5ac363 100644 --- a/src/Bridge/Symfony/Routing/ApiLoader.php +++ b/src/Bridge/Symfony/Routing/ApiLoader.php @@ -126,6 +126,7 @@ public function load($data, $type = null): RouteCollection [ '_controller' => $controller, '_format' => null, + '_stateless' => $operation['stateless'] ?? $resourceMetadata->getAttribute('stateless'), '_api_resource_class' => $operation['resource_class'], '_api_subresource_operation_name' => $operation['route_name'], '_api_subresource_context' => [ @@ -229,6 +230,7 @@ private function addRoute(RouteCollection $routeCollection, string $resourceClas [ '_controller' => $controller, '_format' => null, + '_stateless' => $operation['stateless'], '_api_resource_class' => $resourceClass, sprintf('_api_%s_operation_name', $operationType) => $operationName, ] + ($operation['defaults'] ?? []), diff --git a/src/Metadata/Resource/Factory/OperationResourceMetadataFactory.php b/src/Metadata/Resource/Factory/OperationResourceMetadataFactory.php index eeb41baaacb..931f99ef311 100644 --- a/src/Metadata/Resource/Factory/OperationResourceMetadataFactory.php +++ b/src/Metadata/Resource/Factory/OperationResourceMetadataFactory.php @@ -59,7 +59,7 @@ public function create(string $resourceClass): ResourceMetadata $collectionOperations = $resourceMetadata->getCollectionOperations(); if (null === $collectionOperations) { - $resourceMetadata = $resourceMetadata->withCollectionOperations($this->createOperations($isAbstract ? ['GET'] : ['GET', 'POST'])); + $resourceMetadata = $resourceMetadata->withCollectionOperations($this->createOperations($isAbstract ? ['GET'] : ['GET', 'POST'], $resourceMetadata)); } else { $resourceMetadata = $this->normalize(true, $resourceClass, $resourceMetadata, $collectionOperations); } @@ -76,7 +76,7 @@ public function create(string $resourceClass): ResourceMetadata } } - $resourceMetadata = $resourceMetadata->withItemOperations($this->createOperations($methods)); + $resourceMetadata = $resourceMetadata->withItemOperations($this->createOperations($methods, $resourceMetadata)); } else { $resourceMetadata = $this->normalize(false, $resourceClass, $resourceMetadata, $itemOperations); } @@ -91,11 +91,11 @@ public function create(string $resourceClass): ResourceMetadata return $resourceMetadata; } - private function createOperations(array $methods): array + private function createOperations(array $methods, ResourceMetadata $resourceMetadata): array { $operations = []; foreach ($methods as $method) { - $operations[strtolower($method)] = ['method' => $method]; + $operations[strtolower($method)] = ['method' => $method, 'stateless' => $resourceMetadata->getAttribute('stateless')]; } return $operations; @@ -131,6 +131,8 @@ private function normalize(bool $collection, string $resourceClass, ResourceMeta $operation['method'] = strtoupper($operation['method']); } + $operation['stateless'] = $operation['stateless'] ?? $resourceMetadata->getAttribute('stateless'); + $newOperations[$operationName] = $operation; } diff --git a/src/Operation/Factory/SubresourceOperationFactory.php b/src/Operation/Factory/SubresourceOperationFactory.php index 227b9d494fb..b6dbd4298e5 100644 --- a/src/Operation/Factory/SubresourceOperationFactory.php +++ b/src/Operation/Factory/SubresourceOperationFactory.php @@ -26,7 +26,7 @@ final class SubresourceOperationFactory implements SubresourceOperationFactoryIn { public const SUBRESOURCE_SUFFIX = '_subresource'; public const FORMAT_SUFFIX = '.{_format}'; - public const ROUTE_OPTIONS = ['defaults' => [], 'requirements' => [], 'options' => [], 'host' => '', 'schemes' => [], 'condition' => '', 'controller' => null]; + public const ROUTE_OPTIONS = ['defaults' => [], 'requirements' => [], 'options' => [], 'host' => '', 'schemes' => [], 'condition' => '', 'controller' => null, 'stateless' => null]; private $resourceMetadataFactory; private $propertyNameCollectionFactory; diff --git a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php index 838558f901c..13198e2f1ea 100644 --- a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php +++ b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php @@ -158,6 +158,7 @@ class ApiPlatformExtensionTest extends TestCase ], 'defaults' => [ 'attributes' => [], + 'stateless' => true, ], ]]; @@ -720,7 +721,7 @@ public function testEnableElasticsearch() $containerBuilderProphecy->registerForAutoconfiguration(RequestBodySearchCollectionExtensionInterface::class)->willReturn($this->childDefinitionProphecy)->shouldBeCalled(); $containerBuilderProphecy->setParameter('api_platform.elasticsearch.hosts', ['http://elasticsearch:9200'])->shouldBeCalled(); $containerBuilderProphecy->setParameter('api_platform.elasticsearch.mapping', [])->shouldBeCalled(); - $containerBuilderProphecy->setParameter('api_platform.defaults', ['attributes' => []])->shouldBeCalled(); + $containerBuilderProphecy->setParameter('api_platform.defaults', ['attributes' => ['stateless' => true]])->shouldBeCalled(); $config = self::DEFAULT_CONFIG; $config['api_platform']['elasticsearch'] = [ @@ -871,7 +872,7 @@ private function getPartialContainerBuilderProphecy($configuration = null) 'api_platform.http_cache.vary' => ['Accept'], 'api_platform.http_cache.public' => null, 'api_platform.http_cache.invalidation.max_header_length' => 7500, - 'api_platform.defaults' => ['attributes' => []], + 'api_platform.defaults' => ['attributes' => ['stateless' => true]], 'api_platform.enable_entrypoint' => true, 'api_platform.enable_docs' => true, 'api_platform.url_generation_strategy' => 1, @@ -1175,7 +1176,7 @@ private function getBaseContainerBuilderProphecy(array $doctrineIntegrationsToLo 'api_platform.resource_class_directories' => Argument::type('array'), 'api_platform.validator.serialize_payload_fields' => [], 'api_platform.elasticsearch.enabled' => false, - 'api_platform.defaults' => ['attributes' => []], + 'api_platform.defaults' => ['attributes' => ['stateless' => true]], ]; if ($hasSwagger) { diff --git a/tests/Bridge/Symfony/Routing/ApiLoaderTest.php b/tests/Bridge/Symfony/Routing/ApiLoaderTest.php index b5d464d903e..629b4bd2995 100644 --- a/tests/Bridge/Symfony/Routing/ApiLoaderTest.php +++ b/tests/Bridge/Symfony/Routing/ApiLoaderTest.php @@ -52,21 +52,25 @@ public function testApiLoader() $resourceMetadata = $resourceMetadata->withShortName('dummy'); //default operation based on OperationResourceMetadataFactory $resourceMetadata = $resourceMetadata->withItemOperations([ - 'get' => ['method' => 'GET', 'requirements' => ['id' => '\d+'], 'defaults' => ['my_default' => 'default_value', '_controller' => 'should_not_be_overriden']], - 'put' => ['method' => 'PUT'], - 'delete' => ['method' => 'DELETE'], + 'get' => ['method' => 'GET', 'requirements' => ['id' => '\d+'], 'defaults' => ['my_default' => 'default_value', '_controller' => 'should_not_be_overriden'], 'stateless' => null], + 'put' => ['method' => 'PUT', 'stateless' => null], + 'delete' => ['method' => 'DELETE', 'stateless' => null], ]); //custom operations $resourceMetadata = $resourceMetadata->withCollectionOperations([ - 'my_op' => ['method' => 'GET', 'controller' => 'some.service.name', 'requirements' => ['_format' => 'a valid format'], 'defaults' => ['my_default' => 'default_value'], 'condition' => "request.headers.get('User-Agent') matches '/firefox/i'"], //with controller - 'my_second_op' => ['method' => 'POST', 'options' => ['option' => 'option_value'], 'host' => '{subdomain}.api-platform.com', 'schemes' => ['https']], //without controller, takes the default one - 'my_path_op' => ['method' => 'GET', 'path' => 'some/custom/path'], //custom path + 'my_op' => ['method' => 'GET', 'controller' => 'some.service.name', 'requirements' => ['_format' => 'a valid format'], 'defaults' => ['my_default' => 'default_value'], 'condition' => "request.headers.get('User-Agent') matches '/firefox/i'", 'stateless' => null], //with controller + 'my_second_op' => ['method' => 'POST', 'options' => ['option' => 'option_value'], 'host' => '{subdomain}.api-platform.com', 'schemes' => ['https'], 'stateless' => null], //without controller, takes the default one + 'my_path_op' => ['method' => 'GET', 'path' => 'some/custom/path', 'stateless' => null], //custom path + 'my_stateless_op' => ['method' => 'GET', 'stateless' => true], + ]); + $resourceMetadata = $resourceMetadata->withSubresourceOperations([ + 'subresources_get_subresource' => ['stateless' => true], ]); $routeCollection = $this->getApiLoaderWithResourceMetadata($resourceMetadata)->load(null); $this->assertEquals( - $this->getRoute('/dummies/{id}.{_format}', 'api_platform.action.get_item', DummyEntity::class, 'get', ['GET'], false, ['id' => '\d+'], ['my_default' => 'default_value']), + $this->getRoute('/dummies/{id}.{_format}', 'api_platform.action.get_item', DummyEntity::class, 'get', ['GET'], false, ['id' => '\d+'], ['my_default' => 'default_value', '_stateless' => null]), $routeCollection->get('api_dummies_get_item') ); @@ -81,12 +85,12 @@ public function testApiLoader() ); $this->assertEquals( - $this->getRoute('/dummies.{_format}', 'some.service.name', DummyEntity::class, 'my_op', ['GET'], true, ['_format' => 'a valid format'], ['my_default' => 'default_value'], [], '', [], "request.headers.get('User-Agent') matches '/firefox/i'"), + $this->getRoute('/dummies.{_format}', 'some.service.name', DummyEntity::class, 'my_op', ['GET'], true, ['_format' => 'a valid format'], ['my_default' => 'default_value', '_stateless' => null], [], '', [], "request.headers.get('User-Agent') matches '/firefox/i'"), $routeCollection->get('api_dummies_my_op_collection') ); $this->assertEquals( - $this->getRoute('/dummies.{_format}', 'api_platform.action.post_collection', DummyEntity::class, 'my_second_op', ['POST'], true, [], [], ['option' => 'option_value'], '{subdomain}.api-platform.com', ['https']), + $this->getRoute('/dummies.{_format}', 'api_platform.action.post_collection', DummyEntity::class, 'my_second_op', ['POST'], true, [], ['_stateless' => null], ['option' => 'option_value'], '{subdomain}.api-platform.com', ['https']), $routeCollection->get('api_dummies_my_second_op_collection') ); @@ -96,7 +100,12 @@ public function testApiLoader() ); $this->assertEquals( - $this->getSubresourceRoute('/dummies/{id}/subresources.{_format}', 'api_platform.action.get_subresource', RelatedDummyEntity::class, 'api_dummies_subresources_get_subresource', ['property' => 'subresource', 'identifiers' => [['id', DummyEntity::class, true]], 'collection' => true, 'operationId' => 'api_dummies_subresources_get_subresource']), + $this->getRoute('/dummies.{_format}', 'api_platform.action.get_collection', DummyEntity::class, 'my_stateless_op', ['GET'], true, [], ['_stateless' => true]), + $routeCollection->get('api_dummies_my_stateless_op_collection') + ); + + $this->assertEquals( + $this->getSubresourceRoute('/dummies/{id}/subresources.{_format}', 'api_platform.action.get_subresource', RelatedDummyEntity::class, 'api_dummies_subresources_get_subresource', ['property' => 'subresource', 'identifiers' => [['id', DummyEntity::class, true]], 'collection' => true, 'operationId' => 'api_dummies_subresources_get_subresource'], [], ['_stateless' => true]), $routeCollection->get('api_dummies_subresources_get_subresource') ); } @@ -106,16 +115,16 @@ public function testApiLoaderWithPrefix() $resourceMetadata = new ResourceMetadata(); $resourceMetadata = $resourceMetadata->withShortName('dummy'); $resourceMetadata = $resourceMetadata->withItemOperations([ - 'get' => ['method' => 'GET', 'requirements' => ['id' => '\d+'], 'defaults' => ['my_default' => 'default_value', '_controller' => 'should_not_be_overriden']], - 'put' => ['method' => 'PUT'], - 'delete' => ['method' => 'DELETE'], + 'get' => ['method' => 'GET', 'requirements' => ['id' => '\d+'], 'defaults' => ['my_default' => 'default_value', '_controller' => 'should_not_be_overriden'], 'stateless' => null], + 'put' => ['method' => 'PUT', 'stateless' => null], + 'delete' => ['method' => 'DELETE', 'stateless' => null], ]); $resourceMetadata = $resourceMetadata->withAttributes(['route_prefix' => '/foobar-prefix']); $routeCollection = $this->getApiLoaderWithResourceMetadata($resourceMetadata)->load(null); $this->assertEquals( - $this->getRoute('/foobar-prefix/dummies/{id}.{_format}', 'api_platform.action.get_item', DummyEntity::class, 'get', ['GET'], false, ['id' => '\d+'], ['my_default' => 'default_value']), + $this->getRoute('/foobar-prefix/dummies/{id}.{_format}', 'api_platform.action.get_item', DummyEntity::class, 'get', ['GET'], false, ['id' => '\d+'], ['my_default' => 'default_value', '_stateless' => null]), $routeCollection->get('api_dummies_get_item') ); @@ -142,7 +151,7 @@ public function testNoMethodApiLoader() ]); $resourceMetadata = $resourceMetadata->withCollectionOperations([ - 'get' => ['method' => 'GET'], + 'get' => ['method' => 'GET', 'stateless' => null], ]); $this->getApiLoaderWithResourceMetadata($resourceMetadata)->load(null); @@ -156,11 +165,11 @@ public function testWrongMethodApiLoader() $resourceMetadata = $resourceMetadata->withShortName('dummy'); $resourceMetadata = $resourceMetadata->withItemOperations([ - 'post' => ['method' => 'POST'], + 'post' => ['method' => 'POST', 'stateless' => null], ]); $resourceMetadata = $resourceMetadata->withCollectionOperations([ - 'get' => ['method' => 'GET'], + 'get' => ['method' => 'GET', 'stateless' => null], ]); $this->getApiLoaderWithResourceMetadata($resourceMetadata)->load(null); @@ -178,14 +187,14 @@ public function testRecursiveSubresource() $resourceMetadata = new ResourceMetadata(); $resourceMetadata = $resourceMetadata->withShortName('dummy'); $resourceMetadata = $resourceMetadata->withItemOperations([ - 'get' => ['method' => 'GET'], - 'put' => ['method' => 'PUT'], - 'delete' => ['method' => 'DELETE'], + 'get' => ['method' => 'GET', 'stateless' => null], + 'put' => ['method' => 'PUT', 'stateless' => null], + 'delete' => ['method' => 'DELETE', 'stateless' => null], ]); $resourceMetadata = $resourceMetadata->withCollectionOperations([ - 'my_op' => ['method' => 'GET', 'controller' => 'some.service.name'], //with controller - 'my_second_op' => ['method' => 'POST'], //without controller, takes the default one - 'my_path_op' => ['method' => 'GET', 'path' => 'some/custom/path'], //custom path + 'my_op' => ['method' => 'GET', 'controller' => 'some.service.name', 'stateless' => null], //with controller + 'my_second_op' => ['method' => 'POST', 'stateless' => null], //without controller, takes the default one + 'my_path_op' => ['method' => 'GET', 'path' => 'some/custom/path', 'stateless' => null], //custom path ]); $routeCollection = $this->getApiLoaderWithResourceMetadata($resourceMetadata, true)->load(null); @@ -297,7 +306,7 @@ private function getApiLoaderWithResourceMetadata(ResourceMetadata $resourceMeta return new ApiLoader($kernelProphecy->reveal(), $resourceNameCollectionFactoryProphecy->reveal(), $resourceMetadataFactory, $operationPathResolver, $containerProphecy->reveal(), ['jsonld' => ['application/ld+json']], [], $subresourceOperationFactory, false, true, true, false, false); } - private function getRoute(string $path, string $controller, string $resourceClass, string $operationName, array $methods, bool $collection = false, array $requirements = [], array $extraDefaults = [], array $options = [], string $host = '', array $schemes = [], string $condition = ''): Route + private function getRoute(string $path, string $controller, string $resourceClass, string $operationName, array $methods, bool $collection = false, array $requirements = [], array $extraDefaults = ['_stateless' => null], array $options = [], string $host = '', array $schemes = [], string $condition = ''): Route { return new Route( $path, @@ -316,7 +325,7 @@ private function getRoute(string $path, string $controller, string $resourceClas ); } - private function getSubresourceRoute(string $path, string $controller, string $resourceClass, string $operationName, array $context, array $requirements = []): Route + private function getSubresourceRoute(string $path, string $controller, string $resourceClass, string $operationName, array $context, array $requirements = [], array $extraDefaults = ['_stateless' => null]): Route { return new Route( $path, @@ -326,7 +335,7 @@ private function getSubresourceRoute(string $path, string $controller, string $r '_api_resource_class' => $resourceClass, '_api_subresource_operation_name' => $operationName, '_api_subresource_context' => $context, - ], + ] + $extraDefaults, $requirements, [], '', diff --git a/tests/Fixtures/FileConfigurations/resources.xml b/tests/Fixtures/FileConfigurations/resources.xml index 217167532f6..f12ed385eaa 100644 --- a/tests/Fixtures/FileConfigurations/resources.xml +++ b/tests/Fixtures/FileConfigurations/resources.xml @@ -53,6 +53,7 @@ hydra:Operation File config Dummy + true assertEquals(['foo' => ['bar' => true]], $metadata->getItemOperations()); $this->assertEquals(['baz' => ['tab' => false]], $metadata->getCollectionOperations()); $this->assertEquals(['sub' => ['bus' => false]], $metadata->getSubresourceOperations()); - $this->assertEquals(['a' => 1, 'route_prefix' => '/foobar'], $metadata->getAttributes()); + $this->assertEquals(['a' => 1, 'route_prefix' => '/foobar', 'stateless' => false], $metadata->getAttributes()); $this->assertEquals(['foo' => 'bar'], $metadata->getGraphql()); } @@ -59,6 +59,7 @@ public function testCreateWithDefaults() 'attributes' => [ 'pagination_items_per_page' => 4, 'pagination_maximum_items_per_page' => 6, + 'stateless' => true, ], ]; @@ -81,6 +82,7 @@ public function testCreateWithDefaults() $this->assertTrue($metadata->getAttribute('pagination_client_enabled')); $this->assertEquals(4, $metadata->getAttribute('pagination_items_per_page')); $this->assertEquals(10, $metadata->getAttribute('pagination_maximum_items_per_page')); + $this->assertTrue($metadata->getAttribute('stateless')); } public function testCreateWithoutAttributes() @@ -105,6 +107,7 @@ public function getCreateDependencies() 'subresourceOperations' => ['sub' => ['bus' => false]], 'attributes' => ['a' => 1, 'route_prefix' => '/foobar'], 'graphql' => ['foo' => 'bar'], + 'stateless' => false, ]; $annotationFull = new ApiResource($resourceData); diff --git a/tests/Metadata/Resource/Factory/ExtractorResourceMetadataFactoryTest.php b/tests/Metadata/Resource/Factory/ExtractorResourceMetadataFactoryTest.php index d8e24a76b0d..a5d1dbc552e 100644 --- a/tests/Metadata/Resource/Factory/ExtractorResourceMetadataFactoryTest.php +++ b/tests/Metadata/Resource/Factory/ExtractorResourceMetadataFactoryTest.php @@ -302,6 +302,7 @@ public function testItFallbacksToDefaultConfiguration() 'attributes' => [ 'pagination_items_per_page' => 4, 'pagination_maximum_items_per_page' => 6, + 'stateless' => true, ], ]; $resourceConfiguration = [ @@ -312,6 +313,7 @@ public function testItFallbacksToDefaultConfiguration() 'itemOperations' => ['get', 'delete'], 'attributes' => [ 'pagination_maximum_items_per_page' => 10, + 'stateless' => false, ], ], ]; @@ -338,5 +340,6 @@ public function getResources(): array $this->assertEquals(['get', 'delete'], $metadata->getItemOperations()); $this->assertEquals(4, $metadata->getAttribute('pagination_items_per_page')); $this->assertEquals(10, $metadata->getAttribute('pagination_maximum_items_per_page')); + $this->assertFalse($metadata->getAttribute('stateless')); } } diff --git a/tests/Metadata/Resource/Factory/FileConfigurationMetadataFactoryProvider.php b/tests/Metadata/Resource/Factory/FileConfigurationMetadataFactoryProvider.php index 52f209016aa..39c81511c39 100644 --- a/tests/Metadata/Resource/Factory/FileConfigurationMetadataFactoryProvider.php +++ b/tests/Metadata/Resource/Factory/FileConfigurationMetadataFactoryProvider.php @@ -60,6 +60,7 @@ public function resourceMetadataProvider() '@type' => 'hydra:Operation', '@hydra:title' => 'File config Dummy', ], + 'stateless' => true, ], ]; diff --git a/tests/Metadata/Resource/Factory/OperationResourceMetadataFactoryTest.php b/tests/Metadata/Resource/Factory/OperationResourceMetadataFactoryTest.php index eeeb348d532..f2b79dd84d7 100644 --- a/tests/Metadata/Resource/Factory/OperationResourceMetadataFactoryTest.php +++ b/tests/Metadata/Resource/Factory/OperationResourceMetadataFactoryTest.php @@ -48,25 +48,29 @@ public function getMetadata(): iterable yield [new ResourceMetadata(null, null, null, ['get'], [], null, [], []), new ResourceMetadata(null, null, null, $this->getOperations(['get']), [], null, [], [])]; yield [new ResourceMetadata(null, null, null, ['put'], [], null, [], []), new ResourceMetadata(null, null, null, $this->getOperations(['put']), [], null, [], [])]; yield [new ResourceMetadata(null, null, null, ['delete'], [], null, [], []), new ResourceMetadata(null, null, null, $this->getOperations(['delete']), [], null, [], [])]; - yield [new ResourceMetadata(null, null, null, ['patch' => ['method' => 'PATCH', 'route_name' => 'patch']], [], null, [], []), new ResourceMetadata(null, null, null, ['patch' => ['method' => 'PATCH', 'route_name' => 'patch']], [], null, [], [])]; - yield [new ResourceMetadata(null, null, null, ['patch' => ['method' => 'PATCH', 'route_name' => 'patch']], [], null, [], []), new ResourceMetadata(null, null, null, ['patch' => ['method' => 'PATCH', 'route_name' => 'patch']], [], null, [], []), $jsonapi]; - yield [new ResourceMetadata(null, null, null, ['untouched' => ['method' => 'GET']], [], null, [], []), new ResourceMetadata(null, null, null, ['untouched' => ['method' => 'GET']], [], null, [], []), $jsonapi]; - yield [new ResourceMetadata(null, null, null, ['untouched_custom' => ['route_name' => 'custom_route']], [], null, [], []), new ResourceMetadata(null, null, null, ['untouched_custom' => ['route_name' => 'custom_route']], [], null, [], []), $jsonapi]; + yield [new ResourceMetadata(null, null, null, ['patch' => ['method' => 'PATCH', 'route_name' => 'patch']], [], null, [], []), new ResourceMetadata(null, null, null, ['patch' => ['method' => 'PATCH', 'route_name' => 'patch', 'stateless' => null]], [], null, [], [])]; + yield [new ResourceMetadata(null, null, null, ['patch' => ['method' => 'PATCH', 'route_name' => 'patch']], [], null, [], []), new ResourceMetadata(null, null, null, ['patch' => ['method' => 'PATCH', 'route_name' => 'patch', 'stateless' => null]], [], null, [], []), $jsonapi]; + yield [new ResourceMetadata(null, null, null, ['untouched' => ['method' => 'GET']], [], null, [], []), new ResourceMetadata(null, null, null, ['untouched' => ['method' => 'GET', 'stateless' => null]], [], null, [], []), $jsonapi]; + yield [new ResourceMetadata(null, null, null, ['untouched_custom' => ['route_name' => 'custom_route']], [], null, [], []), new ResourceMetadata(null, null, null, ['untouched_custom' => ['route_name' => 'custom_route', 'stateless' => null]], [], null, [], []), $jsonapi]; + yield [new ResourceMetadata(null, null, null, ['stateless_operation' => ['method' => 'GET', 'stateless' => true]], [], null, [], []), new ResourceMetadata(null, null, null, ['stateless_operation' => ['method' => 'GET', 'stateless' => true]], [], null, [], []), $jsonapi]; + yield [new ResourceMetadata(null, null, null, ['statefull_attribute' => ['method' => 'GET']], [], ['stateless' => false], [], []), new ResourceMetadata(null, null, null, ['statefull_attribute' => ['method' => 'GET', 'stateless' => false]], [], ['stateless' => false], [], []), $jsonapi]; // Collection operations yield [new ResourceMetadata(null, null, null, [], null, null, [], []), new ResourceMetadata(null, null, null, [], $this->getOperations(['get', 'post']), null, [], [])]; yield [new ResourceMetadata(null, null, null, [], ['get'], null, [], []), new ResourceMetadata(null, null, null, [], $this->getOperations(['get']), null, [], [])]; yield [new ResourceMetadata(null, null, null, [], ['post'], null, [], []), new ResourceMetadata(null, null, null, [], $this->getOperations(['post']), null, [], [])]; - yield [new ResourceMetadata(null, null, null, [], ['options' => ['method' => 'OPTIONS', 'route_name' => 'options']], null, [], []), new ResourceMetadata(null, null, null, [], ['options' => ['route_name' => 'options', 'method' => 'OPTIONS']], null, [], [])]; - yield [new ResourceMetadata(null, null, null, [], ['untouched' => ['method' => 'GET']], null, [], []), new ResourceMetadata(null, null, null, [], ['untouched' => ['method' => 'GET']], null, [], [])]; - yield [new ResourceMetadata(null, null, null, [], ['untouched_custom' => ['route_name' => 'custom_route']], null, [], []), new ResourceMetadata(null, null, null, [], ['untouched_custom' => ['route_name' => 'custom_route']], null, [], [])]; + yield [new ResourceMetadata(null, null, null, [], ['options' => ['method' => 'OPTIONS', 'route_name' => 'options']], null, [], []), new ResourceMetadata(null, null, null, [], ['options' => ['route_name' => 'options', 'method' => 'OPTIONS', 'stateless' => null]], null, [], [])]; + yield [new ResourceMetadata(null, null, null, [], ['untouched' => ['method' => 'GET']], null, [], []), new ResourceMetadata(null, null, null, [], ['untouched' => ['method' => 'GET', 'stateless' => null]], null, [], [])]; + yield [new ResourceMetadata(null, null, null, [], ['untouched_custom' => ['route_name' => 'custom_route']], null, [], []), new ResourceMetadata(null, null, null, [], ['untouched_custom' => ['route_name' => 'custom_route', 'stateless' => null]], null, [], [])]; + yield [new ResourceMetadata(null, null, null, [], ['statefull_operation' => ['method' => 'GET', 'stateless' => false]], null, null, []), new ResourceMetadata(null, null, null, [], ['statefull_operation' => ['method' => 'GET', 'stateless' => false]], null, null, []), $jsonapi]; + yield [new ResourceMetadata(null, null, null, [], ['stateless_attribute' => ['method' => 'GET']], ['stateless' => true], null, []), new ResourceMetadata(null, null, null, [], ['stateless_attribute' => ['method' => 'GET', 'stateless' => true]], ['stateless' => true], null, []), $jsonapi]; } private function getOperations(array $names): array { $operations = []; foreach ($names as $name) { - $operations[$name] = ['method' => strtoupper($name)]; + $operations[$name] = ['method' => strtoupper($name), 'stateless' => null]; } return $operations; From ce06420868afb289676478450a3e76569dc20a02 Mon Sep 17 00:00:00 2001 From: Vincent Date: Fri, 25 Sep 2020 12:13:23 +0200 Subject: [PATCH 116/160] Add messenger=persist (#3617) * Add messenger=persist * Add unit tests * Add functional tests * Add test with Mongo * Add dunglas review * Rename LoopDataPersisterInterface in HandOverDataPersisterInterface * Inject DataPersister in Messenger DataPersister * Update unit tests --- features/doctrine/messenger.feature | 51 ++++++++++ .../Bundle/Resources/config/messenger.xml | 1 + .../Symfony/Messenger/DataPersister.php | 69 ++++++++++---- .../Symfony/Messenger/DataTransformer.php | 24 ++--- .../Symfony/Messenger/DataPersisterTest.php | 95 +++++++++++++++++-- .../Symfony/Messenger/DataTransformerTest.php | 26 +++++ .../MessengerWithArrayDtoDataTransformer.php | 45 +++++++++ .../Document/MessengerWithArray.php | 42 ++++++++ .../Document/MessengerWithPersist.php | 41 ++++++++ .../TestBundle/Entity/MessengerWithArray.php | 44 +++++++++ .../Entity/MessengerWithPersist.php | 43 +++++++++ tests/Fixtures/app/config/config_common.yml | 11 +++ 12 files changed, 454 insertions(+), 38 deletions(-) create mode 100644 features/doctrine/messenger.feature create mode 100644 tests/Fixtures/TestBundle/DataTransformer/MessengerWithArrayDtoDataTransformer.php create mode 100644 tests/Fixtures/TestBundle/Document/MessengerWithArray.php create mode 100644 tests/Fixtures/TestBundle/Document/MessengerWithPersist.php create mode 100644 tests/Fixtures/TestBundle/Entity/MessengerWithArray.php create mode 100644 tests/Fixtures/TestBundle/Entity/MessengerWithPersist.php diff --git a/features/doctrine/messenger.feature b/features/doctrine/messenger.feature new file mode 100644 index 00000000000..7abecfce529 --- /dev/null +++ b/features/doctrine/messenger.feature @@ -0,0 +1,51 @@ +Feature: Combine messenger with doctrine + In order to persist and send a resource + As a client software developer + I need to configure the messenger ApiResource attribute properly + + Background: + Given I add "Accept" header equal to "application/ld+json" + And I add "Content-Type" header equal to "application/ld+json" + + @createSchema + Scenario: Using messenger="persist" should combine doctrine and messenger + When I send a "POST" request to "/messenger_with_persists" with body: + """ + { + "name": "test" + } + """ + Then the response status code should be 201 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the JSON should be equal to: + """ + { + "@context": "/contexts/MessengerWithPersist", + "@id": "/messenger_with_persists/1", + "@type": "MessengerWithPersist", + "id": 1, + "name": "test" + } + """ + + Scenario: Using messenger={"persist", "input"} should combine doctrine, messenger and input DTO + When I send a "POST" request to "/messenger_with_arrays" with body: + """ + { + "var": "test" + } + """ + Then the response status code should be 201 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the JSON should be equal to: + """ + { + "@context": "/contexts/MessengerWithArray", + "@id": "/messenger_with_arrays/1", + "@type": "MessengerWithArray", + "id": 1, + "name": "test" + } + """ diff --git a/src/Bridge/Symfony/Bundle/Resources/config/messenger.xml b/src/Bridge/Symfony/Bundle/Resources/config/messenger.xml index d50ec0e3d69..76a4c6d3b51 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/messenger.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/messenger.xml @@ -10,6 +10,7 @@ + diff --git a/src/Bridge/Symfony/Messenger/DataPersister.php b/src/Bridge/Symfony/Messenger/DataPersister.php index faebcad48e8..c7fb6536fe6 100644 --- a/src/Bridge/Symfony/Messenger/DataPersister.php +++ b/src/Bridge/Symfony/Messenger/DataPersister.php @@ -17,6 +17,7 @@ use ApiPlatform\Core\DataPersister\ContextAwareDataPersisterInterface; use ApiPlatform\Core\Exception\ResourceClassNotFoundException; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; use ApiPlatform\Core\Util\ClassInfoTrait; use Symfony\Component\Messenger\Envelope; use Symfony\Component\Messenger\MessageBusInterface; @@ -35,11 +36,13 @@ final class DataPersister implements ContextAwareDataPersisterInterface use DispatchTrait; private $resourceMetadataFactory; + private $dataPersister; - public function __construct(ResourceMetadataFactoryInterface $resourceMetadataFactory, MessageBusInterface $messageBus) + public function __construct(ResourceMetadataFactoryInterface $resourceMetadataFactory, MessageBusInterface $messageBus, ContextAwareDataPersisterInterface $dataPersister) { $this->resourceMetadataFactory = $resourceMetadataFactory; $this->messageBus = $messageBus; + $this->dataPersister = $dataPersister; } /** @@ -47,27 +50,17 @@ public function __construct(ResourceMetadataFactoryInterface $resourceMetadataFa */ public function supports($data, array $context = []): bool { + if (true === ($context['messenger_dispatched'] ?? false)) { + return false; + } + try { $resourceMetadata = $this->resourceMetadataFactory->create($context['resource_class'] ?? $this->getObjectClass($data)); } catch (ResourceClassNotFoundException $e) { return false; } - if (null !== $operationName = $context['collection_operation_name'] ?? $context['item_operation_name'] ?? null) { - return false !== $resourceMetadata->getTypedOperationAttribute( - $context['collection_operation_name'] ?? false ? OperationType::COLLECTION : OperationType::ITEM, - $operationName, - 'messenger', - false, - true - ); - } - - if (isset($context['graphql_operation_name'])) { - return false !== $resourceMetadata->getGraphqlAttribute($context['graphql_operation_name'], 'messenger', false, true); - } - - return false !== $resourceMetadata->getAttribute('messenger', false); + return false !== $this->getMessengerAttributeValue($resourceMetadata, $context); } /** @@ -75,6 +68,10 @@ public function supports($data, array $context = []): bool */ public function persist($data, array $context = []) { + if ($this->handOver($data, $context)) { + $data = $this->dataPersister->persist($data, $context + ['messenger_dispatched' => true]); + } + $envelope = $this->dispatch( (new Envelope($data)) ->with(new ContextStamp($context)) @@ -93,9 +90,49 @@ public function persist($data, array $context = []) */ public function remove($data, array $context = []) { + if ($this->handOver($data, $context)) { + $this->dataPersister->remove($data, $context + ['messenger_dispatched' => true]); + } + $this->dispatch( (new Envelope($data)) ->with(new RemoveStamp()) ); } + + /** + * Should this DataPersister hand over in "persist" mode? + */ + private function handOver($data, array $context = []): bool + { + try { + $value = $this->getMessengerAttributeValue($this->resourceMetadataFactory->create($context['resource_class'] ?? $this->getObjectClass($data)), $context); + } catch (ResourceClassNotFoundException $exception) { + return false; + } + + return 'persist' === $value || (\is_array($value) && (\in_array('persist', $value, true) || (true === $value['persist'] ?? false))); + } + + /** + * @return bool|string|array|null + */ + private function getMessengerAttributeValue(ResourceMetadata $resourceMetadata, array $context = []) + { + if (null !== $operationName = $context['collection_operation_name'] ?? $context['item_operation_name'] ?? null) { + return $resourceMetadata->getTypedOperationAttribute( + $context['collection_operation_name'] ?? false ? OperationType::COLLECTION : OperationType::ITEM, + $operationName, + 'messenger', + false, + true + ); + } + + if (isset($context['graphql_operation_name'])) { + return $resourceMetadata->getGraphqlAttribute($context['graphql_operation_name'], 'messenger', false, true); + } + + return $resourceMetadata->getAttribute('messenger', false); + } } diff --git a/src/Bridge/Symfony/Messenger/DataTransformer.php b/src/Bridge/Symfony/Messenger/DataTransformer.php index dac49c37d21..bdb01ab10c8 100644 --- a/src/Bridge/Symfony/Messenger/DataTransformer.php +++ b/src/Bridge/Symfony/Messenger/DataTransformer.php @@ -55,19 +55,19 @@ public function supportsTransformation($data, string $to, array $context = []): $metadata = $this->resourceMetadataFactory->create($context['resource_class'] ?? $to); if (isset($context['graphql_operation_name'])) { - return 'input' === $metadata->getGraphqlAttribute($context['graphql_operation_name'], 'messenger', null, true); + $attribute = $metadata->getGraphqlAttribute($context['graphql_operation_name'], 'messenger', null, true); + } elseif (!isset($context['operation_type'])) { + $attribute = $metadata->getAttribute('messenger'); + } else { + $attribute = $metadata->getTypedOperationAttribute( + $context['operation_type'], + $context[$context['operation_type'].'_operation_name'] ?? '', + 'messenger', + null, + true + ); } - if (!isset($context['operation_type'])) { - return 'input' === $metadata->getAttribute('messenger'); - } - - return 'input' === $metadata->getTypedOperationAttribute( - $context['operation_type'], - $context[$context['operation_type'].'_operation_name'] ?? '', - 'messenger', - null, - true - ); + return 'input' === $attribute || (\is_array($attribute) && \in_array('input', $attribute, true)); } } diff --git a/tests/Bridge/Symfony/Messenger/DataPersisterTest.php b/tests/Bridge/Symfony/Messenger/DataPersisterTest.php index 7f196e4f293..30b9a9110d0 100644 --- a/tests/Bridge/Symfony/Messenger/DataPersisterTest.php +++ b/tests/Bridge/Symfony/Messenger/DataPersisterTest.php @@ -16,6 +16,7 @@ use ApiPlatform\Core\Bridge\Symfony\Messenger\ContextStamp; use ApiPlatform\Core\Bridge\Symfony\Messenger\DataPersister; use ApiPlatform\Core\Bridge\Symfony\Messenger\RemoveStamp; +use ApiPlatform\Core\DataPersister\ContextAwareDataPersisterInterface; use ApiPlatform\Core\Exception\ResourceClassNotFoundException; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; @@ -26,7 +27,6 @@ use Prophecy\Argument; use Symfony\Component\Messenger\Envelope; use Symfony\Component\Messenger\MessageBusInterface; -use Symfony\Component\Messenger\Stamp\HandledStamp; /** * @author Kévin Dunglas @@ -40,7 +40,7 @@ public function testSupport() $metadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); $metadataFactoryProphecy->create(Dummy::class)->willReturn(new ResourceMetadata(null, null, null, null, null, ['messenger' => true])); - $dataPersister = new DataPersister($metadataFactoryProphecy->reveal(), $this->prophesize(MessageBusInterface::class)->reveal()); + $dataPersister = new DataPersister($metadataFactoryProphecy->reveal(), $this->prophesize(MessageBusInterface::class)->reveal(), $this->prophesize(ContextAwareDataPersisterInterface::class)->reveal()); $this->assertTrue($dataPersister->supports(new Dummy())); } @@ -50,21 +50,39 @@ public function testSupportWithContext() $metadataFactoryProphecy->create(Dummy::class)->willReturn(new ResourceMetadata(null, null, null, null, null, ['messenger' => true])); $metadataFactoryProphecy->create(DummyCar::class)->willThrow(new ResourceClassNotFoundException()); - $dataPersister = new DataPersister($metadataFactoryProphecy->reveal(), $this->prophesize(MessageBusInterface::class)->reveal()); + $dataPersister = new DataPersister($metadataFactoryProphecy->reveal(), $this->prophesize(MessageBusInterface::class)->reveal(), $this->prophesize(ContextAwareDataPersisterInterface::class)->reveal()); $this->assertTrue($dataPersister->supports(new DummyCar(), ['resource_class' => Dummy::class])); $this->assertFalse($dataPersister->supports(new DummyCar())); } + public function testSupportWithContextAndMessengerDispatched() + { + $metadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $metadataFactoryProphecy->create(Dummy::class)->willReturn(new ResourceMetadata(null, null, null, null, null, ['messenger' => true])); + $metadataFactoryProphecy->create(DummyCar::class)->willThrow(new ResourceClassNotFoundException()); + + $dataPersister = new DataPersister($metadataFactoryProphecy->reveal(), $this->prophesize(MessageBusInterface::class)->reveal(), $this->prophesize(ContextAwareDataPersisterInterface::class)->reveal()); + $this->assertFalse($dataPersister->supports(new DummyCar(), ['resource_class' => Dummy::class, 'messenger_dispatched' => true])); + } + public function testPersist() { $dummy = new Dummy(); + $metadataFactory = $this->prophesize(ResourceMetadataFactoryInterface::class); + $metadataFactory->create(Dummy::class)->willReturn(new ResourceMetadata(null, null, null, null, null, ['messenger' => true])); + $metadataFactory->create(Dummy::class)->willReturn(new ResourceMetadata(null, null, null, null, null, ['messenger' => 'input'])); + $metadataFactory->create(DummyCar::class)->willThrow(new ResourceClassNotFoundException()); + + $chainDataPersister = $this->prophesize(ContextAwareDataPersisterInterface::class); + $chainDataPersister->persist($dummy, ['messenger_dispatched' => true])->shouldNotBeCalled(); + $messageBus = $this->prophesize(MessageBusInterface::class); $messageBus->dispatch(Argument::that(function (Envelope $envelope) use ($dummy) { return $dummy === $envelope->getMessage() && null !== $envelope->last(ContextStamp::class); }))->willReturn(new Envelope($dummy))->shouldBeCalled(); - $dataPersister = new DataPersister($this->prophesize(ResourceMetadataFactoryInterface::class)->reveal(), $messageBus->reveal()); + $dataPersister = new DataPersister($metadataFactory->reveal(), $messageBus->reveal(), $chainDataPersister->reveal()); $this->assertSame($dummy, $dataPersister->persist($dummy)); } @@ -72,35 +90,92 @@ public function testRemove() { $dummy = new Dummy(); - $messageBus = $this->prophesize(MessageBusInterface::class); + $metadataFactory = $this->prophesize(ResourceMetadataFactoryInterface::class); + $metadataFactory->create(Dummy::class)->willReturn(new ResourceMetadata(null, null, null, null, null, ['messenger' => true])); + $metadataFactory->create(Dummy::class)->willReturn(new ResourceMetadata(null, null, null, null, null, ['messenger' => 'input'])); + $metadataFactory->create(DummyCar::class)->willThrow(new ResourceClassNotFoundException()); + + $chainDataPersister = $this->prophesize(ContextAwareDataPersisterInterface::class); + $chainDataPersister->remove($dummy, ['messenger_dispatched' => true])->shouldNotBeCalled(); + $messageBus = $this->prophesize(MessageBusInterface::class); $messageBus->dispatch(Argument::that(function (Envelope $envelope) use ($dummy) { return $dummy === $envelope->getMessage() && null !== $envelope->last(RemoveStamp::class); }))->willReturn(new Envelope($dummy))->shouldBeCalled(); - $dataPersister = new DataPersister($this->prophesize(ResourceMetadataFactoryInterface::class)->reveal(), $messageBus->reveal()); + $dataPersister = new DataPersister($metadataFactory->reveal(), $messageBus->reveal(), $chainDataPersister->reveal()); $dataPersister->remove($dummy); } - public function testHandle() + public function testPersistWithHandOver() { $dummy = new Dummy(); + $metadataFactory = $this->prophesize(ResourceMetadataFactoryInterface::class); + $metadataFactory->create(Dummy::class)->willReturn(new ResourceMetadata(null, null, null, null, null, ['messenger' => 'persist'])); + $metadataFactory->create(Dummy::class)->willReturn(new ResourceMetadata(null, null, null, null, null, ['messenger' => ['persist', 'input']])); + $metadataFactory->create(DummyCar::class)->willThrow(new ResourceClassNotFoundException()); + + $chainDataPersister = $this->prophesize(ContextAwareDataPersisterInterface::class); + $chainDataPersister->persist($dummy, ['messenger_dispatched' => true])->willReturn($dummy)->shouldBeCalled(); + $messageBus = $this->prophesize(MessageBusInterface::class); $messageBus->dispatch(Argument::that(function (Envelope $envelope) use ($dummy) { return $dummy === $envelope->getMessage() && null !== $envelope->last(ContextStamp::class); - }))->willReturn((new Envelope($dummy))->with(new HandledStamp($dummy, 'DummyHandler::__invoke')))->shouldBeCalled(); + }))->willReturn(new Envelope($dummy))->shouldBeCalled(); - $dataPersister = new DataPersister($this->prophesize(ResourceMetadataFactoryInterface::class)->reveal(), $messageBus->reveal()); + $dataPersister = new DataPersister($metadataFactory->reveal(), $messageBus->reveal(), $chainDataPersister->reveal()); $this->assertSame($dummy, $dataPersister->persist($dummy)); } + public function testPersistWithExceptionOnHandOver() + { + $dummy = new Dummy(); + + $metadataFactory = $this->prophesize(ResourceMetadataFactoryInterface::class); + $metadataFactory->create(DummyCar::class)->willThrow(new ResourceClassNotFoundException()); + $metadataFactory->create(Dummy::class)->willThrow(new ResourceClassNotFoundException()); + $metadataFactory->create(Dummy::class)->willThrow(new ResourceClassNotFoundException()); + + $chainDataPersister = $this->prophesize(ContextAwareDataPersisterInterface::class); + $chainDataPersister->persist(Argument::any(), Argument::any())->shouldNotBeCalled(); + + $messageBus = $this->prophesize(MessageBusInterface::class); + $messageBus->dispatch(Argument::that(function (Envelope $envelope) use ($dummy) { + return $dummy === $envelope->getMessage() && null !== $envelope->last(ContextStamp::class); + }))->willReturn(new Envelope($dummy))->shouldBeCalled(); + + $dataPersister = new DataPersister($metadataFactory->reveal(), $messageBus->reveal(), $chainDataPersister->reveal()); + $this->assertSame($dummy, $dataPersister->persist($dummy)); + } + + public function testRemoveWithHandOver() + { + $dummy = new Dummy(); + + $metadataFactory = $this->prophesize(ResourceMetadataFactoryInterface::class); + $metadataFactory->create(Dummy::class)->willReturn(new ResourceMetadata(null, null, null, null, null, ['messenger' => 'persist'])); + $metadataFactory->create(Dummy::class)->willReturn(new ResourceMetadata(null, null, null, null, null, ['messenger' => ['persist', 'input']])); + $metadataFactory->create(DummyCar::class)->willThrow(new ResourceClassNotFoundException()); + + $chainDataPersister = $this->prophesize(ContextAwareDataPersisterInterface::class); + $chainDataPersister->remove($dummy, ['messenger_dispatched' => true])->shouldBeCalled(); + + $messageBus = $this->prophesize(MessageBusInterface::class); + $messageBus->dispatch(Argument::that(function (Envelope $envelope) use ($dummy) { + return $dummy === $envelope->getMessage() && null !== $envelope->last(RemoveStamp::class); + }))->willReturn(new Envelope($dummy))->shouldBeCalled(); + + $dataPersister = new DataPersister($metadataFactory->reveal(), $messageBus->reveal(), $chainDataPersister->reveal()); + $dataPersister->remove($dummy); + } + public function testSupportWithGraphqlContext() { $metadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); $metadataFactoryProphecy->create(Dummy::class)->willReturn((new ResourceMetadata(null, null, null, null, null, []))->withGraphQl(['create' => ['messenger' => 'input']])); - $dataPersister = new DataPersister($metadataFactoryProphecy->reveal(), $this->prophesize(MessageBusInterface::class)->reveal()); + $dataPersister = new DataPersister($metadataFactoryProphecy->reveal(), $this->prophesize(MessageBusInterface::class)->reveal(), $this->prophesize(ContextAwareDataPersisterInterface::class)->reveal()); $this->assertTrue($dataPersister->supports(new DummyCar(), ['resource_class' => Dummy::class, 'graphql_operation_name' => 'create'])); } } diff --git a/tests/Bridge/Symfony/Messenger/DataTransformerTest.php b/tests/Bridge/Symfony/Messenger/DataTransformerTest.php index 13e7819130d..82b529d9188 100644 --- a/tests/Bridge/Symfony/Messenger/DataTransformerTest.php +++ b/tests/Bridge/Symfony/Messenger/DataTransformerTest.php @@ -28,6 +28,9 @@ class DataTransformerTest extends TestCase { use ProphecyTrait; + /** + * @dataProvider getMessengerAttribute + */ public function testSupport() { $metadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); @@ -37,6 +40,9 @@ public function testSupport() $this->assertTrue($dataTransformer->supportsTransformation([], Dummy::class, ['input' => ['class' => 'smth']])); } + /** + * @dataProvider getMessengerAttribute + */ public function testSupportWithinRequest() { $metadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); @@ -64,6 +70,9 @@ public function testNoSupportWithinRequest() $this->assertFalse($dataTransformer->supportsTransformation([], Dummy::class, ['input' => ['class' => 'smth'], 'operation_type' => OperationType::ITEM, 'item_operation_name' => 'foo'])); } + /** + * @dataProvider getMessengerAttribute + */ public function testNoSupportWithoutInput() { $metadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); @@ -73,6 +82,9 @@ public function testNoSupportWithoutInput() $this->assertFalse($dataTransformer->supportsTransformation([], Dummy::class, [])); } + /** + * @dataProvider getMessengerAttribute + */ public function testNoSupportWithObject() { $metadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); @@ -90,6 +102,9 @@ public function testTransform() $this->assertSame($dummy, $dataTransformer->transform($dummy, Dummy::class)); } + /** + * @dataProvider getMessengerAttribute + */ public function testSupportWithGraphqlContext() { $metadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); @@ -97,4 +112,15 @@ public function testSupportWithGraphqlContext() $dataTransformer = new DataTransformer($metadataFactoryProphecy->reveal()); $this->assertTrue($dataTransformer->supportsTransformation([], Dummy::class, ['input' => ['class' => 'smth'], 'graphql_operation_name' => 'create'])); } + + public function getMessengerAttribute() + { + yield [ + 'input', + ]; + + yield [ + ['persist', 'input'], + ]; + } } diff --git a/tests/Fixtures/TestBundle/DataTransformer/MessengerWithArrayDtoDataTransformer.php b/tests/Fixtures/TestBundle/DataTransformer/MessengerWithArrayDtoDataTransformer.php new file mode 100644 index 00000000000..0e3f497f981 --- /dev/null +++ b/tests/Fixtures/TestBundle/DataTransformer/MessengerWithArrayDtoDataTransformer.php @@ -0,0 +1,45 @@ + + * + * 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\Tests\Fixtures\TestBundle\DataTransformer; + +use ApiPlatform\Core\DataTransformer\DataTransformerInterface; +use ApiPlatform\Core\Serializer\AbstractItemNormalizer; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\MessengerWithArray as MessengerWithArrayDocument; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Dto\MessengerInput; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\MessengerWithArray as MessengerWithArrayEntity; + +final class MessengerWithArrayDtoDataTransformer implements DataTransformerInterface +{ + /** + * {@inheritdoc} + */ + public function transform($object, string $to, array $context = []) + { + /** @var MessengerInput */ + $data = $object; + + $resourceObject = $context[AbstractItemNormalizer::OBJECT_TO_POPULATE] ?? new $context['resource_class'](); + $resourceObject->name = $data->var; + + return $resourceObject; + } + + /** + * {@inheritdoc} + */ + public function supportsTransformation($object, string $to, array $context = []): bool + { + return \in_array($to, [MessengerWithArrayEntity::class, MessengerWithArrayDocument::class], true) && null !== ($context['input']['class'] ?? null); + } +} diff --git a/tests/Fixtures/TestBundle/Document/MessengerWithArray.php b/tests/Fixtures/TestBundle/Document/MessengerWithArray.php new file mode 100644 index 00000000000..81dc5482192 --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/MessengerWithArray.php @@ -0,0 +1,42 @@ + + * + * 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\Tests\Fixtures\TestBundle\Document; + +use ApiPlatform\Core\Annotation\ApiProperty; +use ApiPlatform\Core\Annotation\ApiResource; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Dto\MessengerInput; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +/** + * @ApiResource(messenger={"persist", "input"}, input=MessengerInput::class) + * @ODM\Document + */ +class MessengerWithArray +{ + /** + * @var int|null + * + * @ApiProperty(identifier=true) + * + * @ODM\Id(strategy="INCREMENT", type="integer") + */ + public $id; + + /** + * @var string|null + * + * @ODM\Field + */ + public $name; +} diff --git a/tests/Fixtures/TestBundle/Document/MessengerWithPersist.php b/tests/Fixtures/TestBundle/Document/MessengerWithPersist.php new file mode 100644 index 00000000000..bc8b317cc60 --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/MessengerWithPersist.php @@ -0,0 +1,41 @@ + + * + * 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\Tests\Fixtures\TestBundle\Document; + +use ApiPlatform\Core\Annotation\ApiProperty; +use ApiPlatform\Core\Annotation\ApiResource; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +/** + * @ApiResource(messenger="persist") + * @ODM\Document + */ +class MessengerWithPersist +{ + /** + * @var int|null + * + * @ApiProperty(identifier=true) + * + * @ODM\Id(strategy="INCREMENT", type="integer") + */ + public $id; + + /** + * @var string|null + * + * @ODM\Field + */ + public $name; +} diff --git a/tests/Fixtures/TestBundle/Entity/MessengerWithArray.php b/tests/Fixtures/TestBundle/Entity/MessengerWithArray.php new file mode 100644 index 00000000000..44035beb686 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/MessengerWithArray.php @@ -0,0 +1,44 @@ + + * + * 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\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Core\Annotation\ApiProperty; +use ApiPlatform\Core\Annotation\ApiResource; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Dto\MessengerInput; +use Doctrine\ORM\Mapping as ORM; + +/** + * @ApiResource(messenger={"persist", "input"}, input=MessengerInput::class) + * @ORM\Entity + */ +class MessengerWithArray +{ + /** + * @var int|null + * + * @ApiProperty(identifier=true) + * + * @ORM\Column(type="integer") + * @ORM\Id + * @ORM\GeneratedValue(strategy="AUTO") + */ + public $id; + + /** + * @var string|null + * + * @ORM\Column(type="string") + */ + public $name; +} diff --git a/tests/Fixtures/TestBundle/Entity/MessengerWithPersist.php b/tests/Fixtures/TestBundle/Entity/MessengerWithPersist.php new file mode 100644 index 00000000000..b0c1716d10c --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/MessengerWithPersist.php @@ -0,0 +1,43 @@ + + * + * 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\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Core\Annotation\ApiProperty; +use ApiPlatform\Core\Annotation\ApiResource; +use Doctrine\ORM\Mapping as ORM; + +/** + * @ApiResource(messenger="persist") + * @ORM\Entity + */ +class MessengerWithPersist +{ + /** + * @var int|null + * + * @ApiProperty(identifier=true) + * + * @ORM\Column(type="integer") + * @ORM\Id + * @ORM\GeneratedValue(strategy="AUTO") + */ + public $id; + + /** + * @var string|null + * + * @ORM\Column(type="string") + */ + public $name; +} diff --git a/tests/Fixtures/app/config/config_common.yml b/tests/Fixtures/app/config/config_common.yml index 9fc68174180..dabcc50d797 100644 --- a/tests/Fixtures/app/config/config_common.yml +++ b/tests/Fixtures/app/config/config_common.yml @@ -11,6 +11,11 @@ framework: profiler: enabled: true collect: false + messenger: + default_bus: messenger.bus.default + buses: + messenger.bus.default: + default_middleware: allow_no_handlers web_profiler: toolbar: true @@ -256,6 +261,12 @@ services: tags: - { name: 'api_platform.data_transformer' } + app.data_transformer.messenger_with_array_dto: + class: 'ApiPlatform\Core\Tests\Fixtures\TestBundle\DataTransformer\MessengerWithArrayDtoDataTransformer' + public: false + tags: + - { name: 'api_platform.data_transformer' } + app.data_transformer.custom_output_dto_fallback_same_class: class: 'ApiPlatform\Core\Tests\Fixtures\TestBundle\DataTransformer\OutputDtoSameClassTransformer' public: false From 87c75d4fd6f4ee0e9be5834ea365c1a27daa1218 Mon Sep 17 00:00:00 2001 From: Anto Date: Wed, 14 Oct 2020 11:05:49 +0200 Subject: [PATCH 117/160] Try to fix master pipeline (#3756) --- .github/workflows/ci.yml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 127991dceab..a544b6d5b3c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -765,9 +765,6 @@ jobs: composer remove --dev --no-progress --no-update --ansi \ doctrine/mongodb-odm \ doctrine/mongodb-odm-bundle \ - - name: Require Symfony Uid - if: (!startsWith(matrix.php, '7.1')) - run: composer require symfony/uid:^5.1 --dev --no-progress --ansi - name: Update project dependencies run: | mkdir -p /tmp/api-platform/core/vendor @@ -891,8 +888,6 @@ jobs: run: | composer global require --prefer-dist --no-progress --no-suggest --ansi \ symfony/flex - - name: Require Symfony Uid - run: composer require symfony/uid --dev --no-progress --ansi - name: Update project dependencies run: | mkdir -p /tmp/api-platform/core/vendor From 89e085fa20bb3ebc39509cda4fc614944fc04011 Mon Sep 17 00:00:00 2001 From: Anto Date: Mon, 12 Oct 2020 19:05:23 +0200 Subject: [PATCH 118/160] Add previous_data to persisters context when available --- src/Util/AttributesExtractor.php | 4 ++++ tests/Util/RequestAttributesExtractorTest.php | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/src/Util/AttributesExtractor.php b/src/Util/AttributesExtractor.php index fcb93a59242..56b73217b9d 100644 --- a/src/Util/AttributesExtractor.php +++ b/src/Util/AttributesExtractor.php @@ -53,6 +53,10 @@ public static function extractAttributes(array $attributes): array } } + if ($previousObject = $attributes['previous_data'] ?? null) { + $result['previous_data'] = $previousObject; + } + if (false === $hasRequestAttributeKey) { return []; } diff --git a/tests/Util/RequestAttributesExtractorTest.php b/tests/Util/RequestAttributesExtractorTest.php index 37b2d082467..9288ba0986c 100644 --- a/tests/Util/RequestAttributesExtractorTest.php +++ b/tests/Util/RequestAttributesExtractorTest.php @@ -189,4 +189,22 @@ public function testOperationNotSet() { $this->assertEmpty(RequestAttributesExtractor::extractAttributes(new Request([], [], ['_api_resource_class' => 'Foo']))); } + + public function testExtractPreviousDataAttributes() + { + $object = new \stdClass(); + $request = new Request([], [], ['_api_resource_class' => 'Foo', '_api_item_operation_name' => 'get', 'previous_data' => $object]); + + $this->assertEquals( + [ + 'resource_class' => 'Foo', + 'item_operation_name' => 'get', + 'receive' => true, + 'respond' => true, + 'persist' => true, + 'previous_data' => $object, + ], + RequestAttributesExtractor::extractAttributes($request) + ); + } } From 42c717b371a366b032fd2cf51e15b5b280c84658 Mon Sep 17 00:00:00 2001 From: Te4g <47118498+Te4g@users.noreply.github.com> Date: Wed, 14 Oct 2020 15:15:58 +0200 Subject: [PATCH 119/160] Fix openApi when no data provided (#3757) --- src/OpenApi/Model/Paths.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/OpenApi/Model/Paths.php b/src/OpenApi/Model/Paths.php index c0b30a9a64c..80829b3d876 100644 --- a/src/OpenApi/Model/Paths.php +++ b/src/OpenApi/Model/Paths.php @@ -29,6 +29,6 @@ public function getPath(string $path): ?PathItem public function getPaths(): array { - return $this->paths; + return $this->paths ?? []; } } From 68163f32c3f30a1fefd20053e90f35d4e38c015f Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Wed, 14 Oct 2020 20:40:35 +0200 Subject: [PATCH 120/160] fix OpenApi operation servers/security (#3758) --- src/OpenApi/Factory/OpenApiFactory.php | 6 +++--- src/OpenApi/Model/Operation.php | 10 +++++----- src/OpenApi/Model/PathItem.php | 6 +++--- src/OpenApi/Serializer/OpenApiNormalizer.php | 1 - tests/OpenApi/Serializer/OpenApiNormalizerTest.php | 8 +++++++- 5 files changed, 18 insertions(+), 13 deletions(-) diff --git a/src/OpenApi/Factory/OpenApiFactory.php b/src/OpenApi/Factory/OpenApiFactory.php index 7ce8cc4d137..dcf35ab13c1 100644 --- a/src/OpenApi/Factory/OpenApiFactory.php +++ b/src/OpenApi/Factory/OpenApiFactory.php @@ -77,7 +77,7 @@ public function __invoke(array $context = []): OpenApi { $baseUrl = $context[self::BASE_URL] ?? '/'; $info = new Model\Info($this->openApiOptions->getTitle(), $this->openApiOptions->getVersion(), trim($this->openApiOptions->getDescription())); - $servers = '/' === $baseUrl || '' === $baseUrl ? [] : [new Model\Server($baseUrl)]; + $servers = '/' === $baseUrl || '' === $baseUrl ? [new Model\Server('/')] : [new Model\Server($baseUrl)]; $paths = new Model\Paths(); $links = []; $schemas = []; @@ -216,8 +216,8 @@ private function collectPaths(ResourceMetadata $resourceMetadata, string $resour $requestBody, isset($operation['openapi_context']['callbacks']) ? new \ArrayObject($operation['openapi_context']['callbacks']) : null, $operation['openapi_context']['deprecated'] ?? (bool) $resourceMetadata->getTypedOperationAttribute($operationType, $operationName, 'deprecation_reason', false, true), - $operation['openapi_context']['security'] ?? [], - $operation['openapi_context']['servers'] ?? [] + $operation['openapi_context']['security'] ?? null, + $operation['openapi_context']['servers'] ?? null )); $paths->addPath($path, $pathItem); diff --git a/src/OpenApi/Model/Operation.php b/src/OpenApi/Model/Operation.php index c5b7fd76faf..f7163dd5c35 100644 --- a/src/OpenApi/Model/Operation.php +++ b/src/OpenApi/Model/Operation.php @@ -30,7 +30,7 @@ final class Operation private $servers; private $externalDocs; - public function __construct(string $operationId = null, array $tags = [], array $responses = [], string $summary = '', string $description = '', ExternalDocumentation $externalDocs = null, array $parameters = [], RequestBody $requestBody = null, \ArrayObject $callbacks = null, bool $deprecated = false, array $security = [], array $servers = []) + public function __construct(string $operationId = null, array $tags = [], array $responses = [], string $summary = '', string $description = '', ExternalDocumentation $externalDocs = null, array $parameters = [], RequestBody $requestBody = null, \ArrayObject $callbacks = null, bool $deprecated = false, ?array $security = null, ?array $servers = null) { $this->tags = $tags; $this->summary = $summary; @@ -103,12 +103,12 @@ public function getDeprecated(): bool return $this->deprecated; } - public function getSecurity(): array + public function getSecurity(): ?array { return $this->security; } - public function getServers(): array + public function getServers(): ?array { return $this->servers; } @@ -193,7 +193,7 @@ public function withDeprecated(bool $deprecated): self return $clone; } - public function withSecurity(array $security): self + public function withSecurity(?array $security = null): self { $clone = clone $this; $clone->security = $security; @@ -201,7 +201,7 @@ public function withSecurity(array $security): self return $clone; } - public function withServers(array $servers): self + public function withServers(?array $servers = null): self { $clone = clone $this; $clone->servers = $servers; diff --git a/src/OpenApi/Model/PathItem.php b/src/OpenApi/Model/PathItem.php index 1285336fa74..5c07c9006c2 100644 --- a/src/OpenApi/Model/PathItem.php +++ b/src/OpenApi/Model/PathItem.php @@ -32,7 +32,7 @@ final class PathItem private $servers; private $parameters; - public function __construct(string $ref = null, string $summary = null, string $description = null, Operation $get = null, Operation $put = null, Operation $post = null, Operation $delete = null, Operation $options = null, Operation $head = null, Operation $patch = null, Operation $trace = null, array $servers = [], array $parameters = []) + public function __construct(string $ref = null, string $summary = null, string $description = null, Operation $get = null, Operation $put = null, Operation $post = null, Operation $delete = null, Operation $options = null, Operation $head = null, Operation $patch = null, Operation $trace = null, ?array $servers = null, array $parameters = []) { $this->ref = $ref; $this->summary = $summary; @@ -104,7 +104,7 @@ public function getTrace(): ?Operation return $this->trace; } - public function getServers(): array + public function getServers(): ?array { return $this->servers; } @@ -202,7 +202,7 @@ public function withTrace(Operation $trace): self return $clone; } - public function withServers(array $servers): self + public function withServers(?array $servers = null): self { $clone = clone $this; $clone->servers = $servers; diff --git a/src/OpenApi/Serializer/OpenApiNormalizer.php b/src/OpenApi/Serializer/OpenApiNormalizer.php index 975e340c14c..381b2599574 100644 --- a/src/OpenApi/Serializer/OpenApiNormalizer.php +++ b/src/OpenApi/Serializer/OpenApiNormalizer.php @@ -24,7 +24,6 @@ final class OpenApiNormalizer implements NormalizerInterface, CacheableSupportsMethodInterface { public const FORMAT = 'json'; - public const OPEN_API_PRESERVE_EMPTY_OBJECTS = 'open_api_preserve_empty_objects'; private const EXTENSION_PROPERTIES_KEY = 'extensionProperties'; private $decorated; diff --git a/tests/OpenApi/Serializer/OpenApiNormalizerTest.php b/tests/OpenApi/Serializer/OpenApiNormalizerTest.php index 30444db0e4c..bed21b8eca2 100644 --- a/tests/OpenApi/Serializer/OpenApiNormalizerTest.php +++ b/tests/OpenApi/Serializer/OpenApiNormalizerTest.php @@ -68,7 +68,7 @@ public function testNormalize() ], [ 'get' => ['method' => 'GET'] + self::OPERATION_FORMATS, - 'post' => ['method' => 'POST'] + self::OPERATION_FORMATS, + 'post' => ['method' => 'POST', 'openapi_context' => ['security' => [], 'servers' => ['url' => '/test']]] + self::OPERATION_FORMATS, ], [] ); @@ -154,5 +154,11 @@ public function testNormalize() $this->assertArrayNotHasKey('termsOfService', $openApiAsArray['info']); $this->assertArrayNotHasKey('paths', $openApiAsArray['paths']); $this->assertArrayHasKey('/dummies/{id}', $openApiAsArray['paths']); + $this->assertArrayNotHasKey('servers', $openApiAsArray['paths']['/dummies/{id}']['get']); + $this->assertArrayNotHasKey('security', $openApiAsArray['paths']['/dummies/{id}']['get']); + + // Security can be disabled per-operation using an empty array + $this->assertEquals([], $openApiAsArray['paths']['/dummies']['post']['security']); + $this->assertEquals(['url' => '/test'], $openApiAsArray['paths']['/dummies']['post']['servers']); } } From b322aff5e97b6cdb783f500c327daf603b2cb8a5 Mon Sep 17 00:00:00 2001 From: ArnoudThibaut Date: Thu, 15 Oct 2020 17:08:39 +0200 Subject: [PATCH 121/160] [GraphQL] Fix filter arguments order (#3468) * add behat test on graphql order filter arguments order * change graphql filter type to preserve argument order * extract array related function into an utility class * update test * fix CS * feat: add a deprecated message when using the old filter syntax * fix: minor fixes * chore: add CHANGELOG entry Co-authored-by: Alan Poulain --- CHANGELOG.md | 1 + features/bootstrap/DoctrineContext.php | 28 ++++++++ features/graphql/filters.feature | 30 ++++++++- src/GraphQl/Resolver/Stage/ReadStage.php | 18 +++++ src/GraphQl/Type/FieldsBuilder.php | 4 +- src/Util/ArrayTrait.php | 42 ++++++++++++ .../GraphQl/Resolver/Stage/ReadStageTest.php | 41 ++++++++++++ tests/GraphQl/Type/FieldsBuilderTest.php | 13 ++-- tests/ProphecyTrait.php | 4 +- tests/Util/ArrayTraitTest.php | 65 +++++++++++++++++++ 10 files changed, 232 insertions(+), 14 deletions(-) create mode 100644 src/Util/ArrayTrait.php create mode 100644 tests/Util/ArrayTraitTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index ef9e6acc205..c8ea4eef666 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ * GraphQL: Possibility to add a custom description for queries, mutations and subscriptions (#3477, #3514) * GraphQL: Support for field name conversion (serialized name) (#3455, #3516) * GraphQL: **BC** `operation` is now `operationName` to follow the standard (#3568) +* GraphQL: **BC** New syntax for the filters' arguments to preserve the order: `order: [{foo: 'asc'}, {bar: 'desc'}]` (#3468) * OpenAPI: Add PHP default values to the documentation (#2386) * Deprecate using a validation groups generator service not implementing `ApiPlatform\Core\Bridge\Symfony\Validator\ValidationGroupsGeneratorInterface` (#3346) * Subresources: subresource resourceClass can now be defined as a container parameter in XML and Yaml definitions diff --git a/features/bootstrap/DoctrineContext.php b/features/bootstrap/DoctrineContext.php index c6c4ba87e9e..b451fef9745 100644 --- a/features/bootstrap/DoctrineContext.php +++ b/features/bootstrap/DoctrineContext.php @@ -448,6 +448,34 @@ public function thereAreDummyObjectsWithRelatedDummy(int $nb) $this->manager->flush(); } + /** + * @Given there are dummies with similar properties + */ + public function thereAreDummiesWithSimilarProperties() + { + $dummy1 = $this->buildDummy(); + $dummy1->setName('foo'); + $dummy1->setDescription('bar'); + + $dummy2 = $this->buildDummy(); + $dummy2->setName('baz'); + $dummy2->setDescription('qux'); + + $dummy3 = $this->buildDummy(); + $dummy3->setName('foo'); + $dummy3->setDescription('qux'); + + $dummy4 = $this->buildDummy(); + $dummy4->setName('baz'); + $dummy4->setDescription('bar'); + + $this->manager->persist($dummy1); + $this->manager->persist($dummy2); + $this->manager->persist($dummy3); + $this->manager->persist($dummy4); + $this->manager->flush(); + } + /** * @Given there are :nb dummyDtoNoInput objects */ diff --git a/features/graphql/filters.feature b/features/graphql/filters.feature index 54254b20258..b094006013d 100644 --- a/features/graphql/filters.feature +++ b/features/graphql/filters.feature @@ -30,7 +30,7 @@ Feature: Collections filtering When I send the following GraphQL request: """ { - dummies(exists: {relatedDummy: true}) { + dummies(exists: [{relatedDummy: true}]) { edges { node { id @@ -52,7 +52,7 @@ Feature: Collections filtering When I send the following GraphQL request: """ { - dummies(dummyDate: {after: "2015-04-02"}) { + dummies(dummyDate: [{after: "2015-04-02"}]) { edges { node { id @@ -204,7 +204,7 @@ Feature: Collections filtering When I send the following GraphQL request: """ { - dummies(order: {relatedDummy__name: "DESC"}) { + dummies(order: [{relatedDummy__name: "DESC"}]) { edges { node { name @@ -222,6 +222,30 @@ Feature: Collections filtering And the JSON node "data.dummies.edges[0].node.name" should be equal to "Dummy #2" And the JSON node "data.dummies.edges[1].node.name" should be equal to "Dummy #1" + @createSchema + Scenario: Retrieve a collection ordered correctly given the order of the argument + Given there are dummies with similar properties + When I send the following GraphQL request: + """ + { + dummies(order: [{description: "ASC"}, {name: "ASC"}]) { + edges { + node { + id + name + description + } + } + } + } + """ + Then the response status code should be 200 + And the header "Content-Type" should be equal to "application/json" + And the JSON node "data.dummies.edges[0].node.name" should be equal to "baz" + And the JSON node "data.dummies.edges[0].node.description" should be equal to "bar" + And the JSON node "data.dummies.edges[1].node.name" should be equal to "foo" + And the JSON node "data.dummies.edges[1].node.description" should be equal to "bar" + @createSchema Scenario: Retrieve a collection filtered using the related search filter with two values and exact strategy Given there are 3 dummy objects with relatedDummy diff --git a/src/GraphQl/Resolver/Stage/ReadStage.php b/src/GraphQl/Resolver/Stage/ReadStage.php index 88a17d3fadf..213976035c5 100644 --- a/src/GraphQl/Resolver/Stage/ReadStage.php +++ b/src/GraphQl/Resolver/Stage/ReadStage.php @@ -21,6 +21,7 @@ use ApiPlatform\Core\GraphQl\Serializer\ItemNormalizer; use ApiPlatform\Core\GraphQl\Serializer\SerializerContextBuilderInterface; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\Util\ArrayTrait; use ApiPlatform\Core\Util\ClassInfoTrait; use GraphQL\Type\Definition\ResolveInfo; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; @@ -36,6 +37,7 @@ final class ReadStage implements ReadStageInterface { use ClassInfoTrait; use IdentifierTrait; + use ArrayTrait; private $resourceMetadataFactory; private $iriConverter; @@ -134,6 +136,22 @@ private function getNormalizedFilters(array $args): array if (strpos($name, '_list')) { $name = substr($name, 0, \strlen($name) - \strlen('_list')); } + + // If the value contains arrays, we need to merge them for the filters to understand this syntax, proper to GraphQL to preserve the order of the arguments. + if ($this->isSequentialArrayOfArrays($value)) { + if (\count($value[0]) > 1) { + $deprecationMessage = "The filter syntax \"$name: {"; + $filterArgsOld = []; + $filterArgsNew = []; + foreach ($value[0] as $filterArgName => $filterArgValue) { + $filterArgsOld[] = "$filterArgName: \"$filterArgValue\""; + $filterArgsNew[] = sprintf('{%s: "%s"}', $filterArgName, $filterArgValue); + } + $deprecationMessage .= sprintf('%s}" is deprecated since API Platform 2.6, use the following syntax instead: "%s: [%s]".', implode(', ', $filterArgsOld), $name, implode(', ', $filterArgsNew)); + @trigger_error($deprecationMessage, E_USER_DEPRECATED); + } + $value = array_merge(...$value); + } $filters[$name] = $this->getNormalizedFilters($value); } diff --git a/src/GraphQl/Type/FieldsBuilder.php b/src/GraphQl/Type/FieldsBuilder.php index 85e9d24ac62..a73cb2aba57 100644 --- a/src/GraphQl/Type/FieldsBuilder.php +++ b/src/GraphQl/Type/FieldsBuilder.php @@ -451,10 +451,10 @@ private function convertFilterArgsToTypes(array $args): array unset($value['#name']); - $filterArgType = new InputObjectType([ + $filterArgType = GraphQLType::listOf(new InputObjectType([ 'name' => $name, 'fields' => $this->convertFilterArgsToTypes($value), - ]); + ])); $this->typesContainer->set($name, $filterArgType); diff --git a/src/Util/ArrayTrait.php b/src/Util/ArrayTrait.php new file mode 100644 index 00000000000..788ddac2c49 --- /dev/null +++ b/src/Util/ArrayTrait.php @@ -0,0 +1,42 @@ + + * + * 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\Util; + +trait ArrayTrait +{ + public function isSequentialArrayOfArrays(array $array): bool + { + if (!$this->isSequentialArray($array)) { + return false; + } + + return $this->arrayContainsOnly($array, 'array'); + } + + public function isSequentialArray(array $array): bool + { + if ([] === $array) { + return false; + } + + return array_keys($array) === range(0, \count($array) - 1); + } + + public function arrayContainsOnly(array $array, string $type): bool + { + return $array === array_filter($array, static function ($item) use ($type): bool { + return $type === \gettype($item); + }); + } +} diff --git a/tests/GraphQl/Resolver/Stage/ReadStageTest.php b/tests/GraphQl/Resolver/Stage/ReadStageTest.php index 447622dc2b9..981d3a56693 100644 --- a/tests/GraphQl/Resolver/Stage/ReadStageTest.php +++ b/tests/GraphQl/Resolver/Stage/ReadStageTest.php @@ -25,6 +25,7 @@ use ApiPlatform\Core\Tests\ProphecyTrait; use GraphQL\Type\Definition\ResolveInfo; use PHPUnit\Framework\TestCase; +use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; /** @@ -32,6 +33,7 @@ */ class ReadStageTest extends TestCase { + use ExpectDeprecationTrait; use ProphecyTrait; /** @var ReadStage */ @@ -230,6 +232,13 @@ public function collectionProvider(): array ['filter_list' => 'filtered', 'filter_field_list' => ['filtered1', 'filtered2'], 'filter.list' => 'filtered', 'filter_field' => ['filtered1', 'filtered2'], 'filter.field' => ['filtered1', 'filtered2']], [], ], + 'with array filter syntax' => [ + ['filter' => [['filterArg1' => 'filterValue1'], ['filterArg2' => 'filterValue2']]], + 'myResource', + null, + ['filter' => ['filterArg1' => 'filterValue1', 'filterArg2' => 'filterValue2']], + [], + ], 'with subresource' => [ [], 'myResource', @@ -239,4 +248,36 @@ public function collectionProvider(): array ], ]; } + + /** + * @group legacy + */ + public function testApplyCollectionWithDeprecatedFilterSyntax(): void + { + $operationName = 'collection_query'; + $resourceClass = 'myResource'; + $info = $this->prophesize(ResolveInfo::class)->reveal(); + $fieldName = 'subresource'; + $info->fieldName = $fieldName; + $context = [ + 'is_collection' => true, + 'is_mutation' => false, + 'is_subscription' => false, + 'args' => ['filter' => [['filterArg1' => 'filterValue1', 'filterArg2' => 'filterValue2']]], + 'info' => $info, + 'source' => null, + ]; + $this->resourceMetadataFactoryProphecy->create($resourceClass)->willReturn(new ResourceMetadata()); + + $normalizationContext = ['normalization' => true]; + $this->serializerContextBuilderProphecy->create($resourceClass, $operationName, $context, true)->shouldBeCalled()->willReturn($normalizationContext); + + $this->collectionDataProviderProphecy->getCollection($resourceClass, $operationName, $normalizationContext + ['filters' => ['filter' => ['filterArg1' => 'filterValue1', 'filterArg2' => 'filterValue2']]])->willReturn([]); + + $this->expectDeprecation('The filter syntax "filter: {filterArg1: "filterValue1", filterArg2: "filterValue2"}" is deprecated since API Platform 2.6, use the following syntax instead: "filter: [{filterArg1: "filterValue1"}, {filterArg2: "filterValue2"}]".'); + + $result = ($this->readStage)($resourceClass, 'myResource', $operationName, $context); + + $this->assertSame([], $result); + } } diff --git a/tests/GraphQl/Type/FieldsBuilderTest.php b/tests/GraphQl/Type/FieldsBuilderTest.php index 6fe5922e969..df2f13f055a 100644 --- a/tests/GraphQl/Type/FieldsBuilderTest.php +++ b/tests/GraphQl/Type/FieldsBuilderTest.php @@ -31,6 +31,7 @@ use ApiPlatform\Core\Tests\ProphecyTrait; use GraphQL\Type\Definition\InputObjectType; use GraphQL\Type\Definition\InterfaceType; +use GraphQL\Type\Definition\ListOfType; use GraphQL\Type\Definition\NonNull; use GraphQL\Type\Definition\ObjectType; use GraphQL\Type\Definition\Type as GraphQLType; @@ -233,8 +234,8 @@ public function testGetCollectionQueryFields(string $resourceClass, ResourceMeta $this->filterLocatorProphecy->get('my_filter')->willReturn($filterProphecy->reveal()); $this->typesContainerProphecy->has('ShortNameFilter_dateField')->willReturn(false); $this->typesContainerProphecy->has('ShortNameFilter_parent__child')->willReturn(false); - $this->typesContainerProphecy->set('ShortNameFilter_dateField', Argument::type(InputObjectType::class)); - $this->typesContainerProphecy->set('ShortNameFilter_parent__child', Argument::type(InputObjectType::class)); + $this->typesContainerProphecy->set('ShortNameFilter_dateField', Argument::type(ListOfType::class)); + $this->typesContainerProphecy->set('ShortNameFilter_parent__child', Argument::type(ListOfType::class)); $queryFields = $this->fieldsBuilder->getCollectionQueryFields($resourceClass, $resourceMetadata, $queryName, $configuration); @@ -299,8 +300,8 @@ public function collectionQueryFieldsProvider(): array ], 'boolField' => $graphqlType, 'boolField_list' => GraphQLType::listOf($graphqlType), - 'parent__child' => new InputObjectType(['name' => 'ShortNameFilter_parent__child', 'fields' => ['related__nested' => $graphqlType]]), - 'dateField' => new InputObjectType(['name' => 'ShortNameFilter_dateField', 'fields' => ['before' => $graphqlType]]), + 'parent__child' => GraphQLType::listOf(new InputObjectType(['name' => 'ShortNameFilter_parent__child', 'fields' => ['related__nested' => $graphqlType]])), + 'dateField' => GraphQLType::listOf(new InputObjectType(['name' => 'ShortNameFilter_dateField', 'fields' => ['before' => $graphqlType]])), ], 'resolve' => $resolver, 'deprecationReason' => null, @@ -351,8 +352,8 @@ public function collectionQueryFieldsProvider(): array ], 'boolField' => $graphqlType, 'boolField_list' => GraphQLType::listOf($graphqlType), - 'parent__child' => new InputObjectType(['name' => 'ShortNameFilter_parent__child', 'fields' => ['related__nested' => $graphqlType]]), - 'dateField' => new InputObjectType(['name' => 'ShortNameFilter_dateField', 'fields' => ['before' => $graphqlType]]), + 'parent__child' => GraphQLType::listOf(new InputObjectType(['name' => 'ShortNameFilter_parent__child', 'fields' => ['related__nested' => $graphqlType]])), + 'dateField' => GraphQLType::listOf(new InputObjectType(['name' => 'ShortNameFilter_dateField', 'fields' => ['before' => $graphqlType]])), ], 'resolve' => $resolver, 'deprecationReason' => null, diff --git a/tests/ProphecyTrait.php b/tests/ProphecyTrait.php index 439608d5a4b..e9058776377 100644 --- a/tests/ProphecyTrait.php +++ b/tests/ProphecyTrait.php @@ -110,9 +110,7 @@ private function countProphecyAssertions(): void $this->prophecyAssertionsCounted = true; foreach ($this->prophet->getProphecies() as $objectProphecy) { - /** - * @var MethodProphecy[] $methodProphecies - */ + /** @var MethodProphecy[] $methodProphecies */ foreach ($objectProphecy->getMethodProphecies() as $methodProphecies) { foreach ($methodProphecies as $methodProphecy) { \assert($methodProphecy instanceof MethodProphecy); diff --git a/tests/Util/ArrayTraitTest.php b/tests/Util/ArrayTraitTest.php new file mode 100644 index 00000000000..98671f0603f --- /dev/null +++ b/tests/Util/ArrayTraitTest.php @@ -0,0 +1,65 @@ + + * + * 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\Tests\Util; + +use ApiPlatform\Core\Util\ArrayTrait; +use PHPUnit\Framework\TestCase; + +class ArrayTraitTest extends TestCase +{ + private $arrayTraitClass; + + protected function setUp(): void + { + $this->arrayTraitClass = (new class() { + use ArrayTrait; + }); + } + + public function testIsSequentialArrayWithEmptyArray(): void + { + self::assertFalse($this->arrayTraitClass->isSequentialArray([])); + } + + public function testIsSequentialArrayWithNonNumericIndex(): void + { + self::assertFalse($this->arrayTraitClass->isSequentialArray(['foo' => 'bar'])); + } + + public function testIsSequentialArrayWithNumericNonSequentialIndex(): void + { + self::assertFalse($this->arrayTraitClass->isSequentialArray([1 => 'bar', 3 => 'foo'])); + } + + public function testIsSequentialArrayWithNumericSequentialIndex(): void + { + self::assertTrue($this->arrayTraitClass->isSequentialArray([0 => 'bar', 1 => 'foo'])); + } + + public function testArrayContainsOnlyWithDifferentTypes(): void + { + self::assertFalse($this->arrayTraitClass->arrayContainsOnly([1, 'foo'], 'string')); + } + + public function testArrayContainsOnlyWithSameType(): void + { + self::assertTrue($this->arrayTraitClass->arrayContainsOnly(['foo', 'bar'], 'string')); + } + + public function testIsSequentialArrayOfArrays(): void + { + self::assertFalse($this->arrayTraitClass->isSequentialArrayOfArrays([])); + self::assertTrue($this->arrayTraitClass->isSequentialArrayOfArrays([['foo'], ['bar']])); + } +} From 1c7c26d0e8f505e493fa31397255d2de2c5dce13 Mon Sep 17 00:00:00 2001 From: PierreThibaudeau Date: Thu, 15 Oct 2020 19:05:09 +0200 Subject: [PATCH 122/160] feat: improves iri_only implementation (#3454) * Issue-#3275 - feat: Improves iri_only implementation (#3454) * Add back default context * fix: minor fixes Co-authored-by: soyuka Co-authored-by: Alan Poulain --- features/bootstrap/DoctrineContext.php | 24 +++++++ features/jsonld/iri_only.feature | 50 ++++++++++++++ src/Hydra/Serializer/CollectionNormalizer.php | 3 +- src/JsonLd/ContextBuilder.php | 7 ++ src/Serializer/SerializerContextBuilder.php | 1 + .../TestBundle/Document/IriOnlyDummy.php | 62 +++++++++++++++++ .../TestBundle/Entity/IriOnlyDummy.php | 64 ++++++++++++++++++ .../Serializer/CollectionNormalizerTest.php | 67 +++++++++++++++++-- tests/JsonLd/ContextBuilderTest.php | 19 ++++++ .../SerializerContextBuilderTest.php | 14 ++-- 10 files changed, 298 insertions(+), 13 deletions(-) create mode 100644 features/jsonld/iri_only.feature create mode 100644 tests/Fixtures/TestBundle/Document/IriOnlyDummy.php create mode 100644 tests/Fixtures/TestBundle/Entity/IriOnlyDummy.php diff --git a/features/bootstrap/DoctrineContext.php b/features/bootstrap/DoctrineContext.php index b451fef9745..5fbc3eee41e 100644 --- a/features/bootstrap/DoctrineContext.php +++ b/features/bootstrap/DoctrineContext.php @@ -54,6 +54,7 @@ use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\FourthLevel as FourthLevelDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\Greeting as GreetingDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\InitializeInput as InitializeInputDocument; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\IriOnlyDummy as IriOnlyDummyDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\MaxDepthDummy as MaxDepthDummyDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\NetworkPathDummy as NetworkPathDummyDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\NetworkPathRelationDummy as NetworkPathRelationDummyDocument; @@ -120,6 +121,7 @@ use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Greeting; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\InitializeInput; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\InternalUser; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\IriOnlyDummy; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\MaxDepthDummy; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\NetworkPathDummy; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\NetworkPathRelationDummy; @@ -1585,6 +1587,20 @@ public function thereAreDummyMercureObjects(int $nb) $this->manager->flush(); } + /** + * @Given there are :nb iriOnlyDummies + */ + public function thereAreIriOnlyDummies(int $nb) + { + for ($i = 1; $i <= $nb; ++$i) { + $iriOnlyDummy = $this->buildIriOnlyDummy(); + $iriOnlyDummy->setFoo('bar'.$nb); + $this->manager->persist($iriOnlyDummy); + } + + $this->manager->flush(); + } + /** * @Given there are :nb absoluteUrlDummy objects with a related absoluteUrlRelationDummy */ @@ -1867,6 +1883,14 @@ private function buildGreeting() return $this->isOrm() ? new Greeting() : new GreetingDocument(); } + /** + * @return IriOnlyDummy|IriOnlyDummyDocument + */ + private function buildIriOnlyDummy() + { + return $this->isOrm() ? new IriOnlyDummy() : new IriOnlyDummyDocument(); + } + /** * @return MaxDepthDummy|MaxDepthDummyDocument */ diff --git a/features/jsonld/iri_only.feature b/features/jsonld/iri_only.feature new file mode 100644 index 00000000000..2e2e3ad5915 --- /dev/null +++ b/features/jsonld/iri_only.feature @@ -0,0 +1,50 @@ +Feature: JSON-LD using iri_only parameter + In order to improve Vulcain support + As a Vulcain user and as a developer + I should be able to only get an IRI list when I ask a resource. + + Scenario: Retrieve Dummy's resource context with iri_only + When I send a "GET" request to "/contexts/IriOnlyDummy" + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the JSON should be equal to: + """ + { + "@context": { + "@vocab": "http://example.com/docs.jsonld#", + "hydra": "http://www.w3.org/ns/hydra/core#", + "hydra:member": { + "@type": "@id" + } + } + } + """ + + @createSchema + Scenario: Retrieve Dummies with iri_only and jsonld_embed_context + Given there are 3 iriOnlyDummies + When I send a "GET" request to "/iri_only_dummies" + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the JSON should be equal to: + """ + { + "@context": { + "@vocab": "http://example.com/docs.jsonld#", + "hydra": "http://www.w3.org/ns/hydra/core#", + "hydra:member": { + "@type": "@id" + } + }, + "@id": "/iri_only_dummies", + "@type": "hydra:Collection", + "hydra:member": [ + "/iri_only_dummies/1", + "/iri_only_dummies/2", + "/iri_only_dummies/3" + ], + "hydra:totalItems": 3 + } + """ diff --git a/src/Hydra/Serializer/CollectionNormalizer.php b/src/Hydra/Serializer/CollectionNormalizer.php index 60d61499a67..dcfc7ab7c21 100644 --- a/src/Hydra/Serializer/CollectionNormalizer.php +++ b/src/Hydra/Serializer/CollectionNormalizer.php @@ -86,11 +86,10 @@ public function normalize($object, $format = null, array $context = []) } $data['@type'] = 'hydra:Collection'; - $data['hydra:member'] = []; $iriOnly = $context[self::IRI_ONLY] ?? $this->defaultContext[self::IRI_ONLY]; foreach ($object as $obj) { - $data['hydra:member'][] = $iriOnly ? ['@id' => $this->iriConverter->getIriFromItem($obj)] : $this->normalizer->normalize($obj, $format, $context); + $data['hydra:member'][] = $iriOnly ? $this->iriConverter->getIriFromItem($obj) : $this->normalizer->normalize($obj, $format, $context); } if ($object instanceof PaginatorInterface) { diff --git a/src/JsonLd/ContextBuilder.php b/src/JsonLd/ContextBuilder.php index b0d544f94df..6d5e715abd8 100644 --- a/src/JsonLd/ContextBuilder.php +++ b/src/JsonLd/ContextBuilder.php @@ -95,6 +95,13 @@ public function getResourceContext(string $resourceClass, int $referenceType = U return []; } + if ($resourceMetadata->getAttribute('normalization_context')['iri_only'] ?? false) { + $context = $this->getBaseContext($referenceType); + $context['hydra:member']['@type'] = '@id'; + + return $context; + } + return $this->getResourceContextWithShortname($resourceClass, $referenceType, $shortName); } diff --git a/src/Serializer/SerializerContextBuilder.php b/src/Serializer/SerializerContextBuilder.php index 6cab2e7265f..fc93897d72f 100644 --- a/src/Serializer/SerializerContextBuilder.php +++ b/src/Serializer/SerializerContextBuilder.php @@ -72,6 +72,7 @@ public function createFromRequest(Request $request, bool $normalization, array $ } $context['resource_class'] = $attributes['resource_class']; + $context['iri_only'] = $resourceMetadata->getAttribute('normalization_context')['iri_only'] ?? false; $context['input'] = $resourceMetadata->getTypedOperationAttribute($operationType, $attributes[$operationKey], 'input', null, true); $context['output'] = $resourceMetadata->getTypedOperationAttribute($operationType, $attributes[$operationKey], 'output', null, true); $context['request_uri'] = $request->getRequestUri(); diff --git a/tests/Fixtures/TestBundle/Document/IriOnlyDummy.php b/tests/Fixtures/TestBundle/Document/IriOnlyDummy.php new file mode 100644 index 00000000000..b278a71fd20 --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/IriOnlyDummy.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\Tests\Fixtures\TestBundle\Document; + +use ApiPlatform\Core\Annotation\ApiResource; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +/** + * Dummy with iri_only. + * + * @author Pierre Thibaudeau + * + * @ApiResource( + * normalizationContext={ + * "iri_only"=true, + * "jsonld_embed_context"=true + * } + * ) + * @ODM\Document + */ +class IriOnlyDummy +{ + /** + * @var int The id + * + * @ODM\Id(strategy="INCREMENT", type="integer") + */ + private $id; + + /** + * @var string + * + * @ODM\Field(type="string") + */ + private $foo; + + public function getId(): int + { + return $this->id; + } + + public function getFoo(): string + { + return $this->foo; + } + + public function setFoo(string $foo): void + { + $this->foo = $foo; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/IriOnlyDummy.php b/tests/Fixtures/TestBundle/Entity/IriOnlyDummy.php new file mode 100644 index 00000000000..ffb6cbd8d6b --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/IriOnlyDummy.php @@ -0,0 +1,64 @@ + + * + * 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\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Core\Annotation\ApiResource; +use Doctrine\ORM\Mapping as ORM; + +/** + * Dummy with iri_only. + * + * @author Pierre Thibaudeau + * + * @ApiResource( + * normalizationContext={ + * "iri_only"=true, + * "jsonld_embed_context"=true + * } + * ) + * @ORM\Entity + */ +class IriOnlyDummy +{ + /** + * @var int The id + * + * @ORM\Column(type="integer") + * @ORM\Id + * @ORM\GeneratedValue(strategy="AUTO") + */ + private $id; + + /** + * @var string + * + * @ORM\Column(type="string") + */ + private $foo; + + public function getId(): int + { + return $this->id; + } + + public function getFoo(): string + { + return $this->foo; + } + + public function setFoo(string $foo): void + { + $this->foo = $foo; + } +} diff --git a/tests/Hydra/Serializer/CollectionNormalizerTest.php b/tests/Hydra/Serializer/CollectionNormalizerTest.php index 72a74cd17ce..352d5e60cb3 100644 --- a/tests/Hydra/Serializer/CollectionNormalizerTest.php +++ b/tests/Hydra/Serializer/CollectionNormalizerTest.php @@ -360,12 +360,12 @@ public function testNormalizeIriOnlyResourceCollection(): void $delegateNormalizerProphecy = $this->prophesize(NormalizerInterface::class); - $normalizer = new CollectionNormalizer($contextBuilderProphecy->reveal(), $resourceClassResolverProphecy->reveal(), $iriConverterProphecy->reveal(), [CollectionNormalizer::IRI_ONLY => true]); + $normalizer = new CollectionNormalizer($contextBuilderProphecy->reveal(), $resourceClassResolverProphecy->reveal(), $iriConverterProphecy->reveal()); $normalizer->setNormalizer($delegateNormalizerProphecy->reveal()); $actual = $normalizer->normalize($data, CollectionNormalizer::FORMAT, [ 'collection_operation_name' => 'get', - 'operation_type' => OperationType::COLLECTION, + 'iri_only' => true, 'resource_class' => Foo::class, ]); @@ -374,8 +374,67 @@ public function testNormalizeIriOnlyResourceCollection(): void '@id' => '/foos', '@type' => 'hydra:Collection', 'hydra:member' => [ - ['@id' => '/foos/1'], - ['@id' => '/foos/3'], + '/foos/1', + '/foos/3', + ], + 'hydra:totalItems' => 2, + ], $actual); + } + + public function testNormalizeIriOnlyEmbedContextResourceCollection(): void + { + $fooOne = new Foo(); + $fooOne->id = 1; + $fooOne->bar = 'baz'; + + $fooThree = new Foo(); + $fooThree->id = 3; + $fooThree->bar = 'bzz'; + + $data = [$fooOne, $fooThree]; + + $contextBuilderProphecy = $this->prophesize(ContextBuilderInterface::class); + $contextBuilderProphecy->getResourceContext(Foo::class)->willReturn([ + '@vocab' => 'http://localhost:8080/docs.jsonld#', + 'hydra' => 'http://www.w3.org/ns/hydra/core#', + 'hydra:member' => [ + '@type' => '@id', + ], + ]); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass($data, Foo::class)->willReturn(Foo::class); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getIriFromResourceClass(Foo::class)->willReturn('/foos'); + $iriConverterProphecy->getIriFromItem($fooOne)->willReturn('/foos/1'); + $iriConverterProphecy->getIriFromItem($fooThree)->willReturn('/foos/3'); + + $delegateNormalizerProphecy = $this->prophesize(NormalizerInterface::class); + + $normalizer = new CollectionNormalizer($contextBuilderProphecy->reveal(), $resourceClassResolverProphecy->reveal(), $iriConverterProphecy->reveal()); + $normalizer->setNormalizer($delegateNormalizerProphecy->reveal()); + + $actual = $normalizer->normalize($data, CollectionNormalizer::FORMAT, [ + 'collection_operation_name' => 'get', + 'iri_only' => true, + 'jsonld_embed_context' => true, + 'resource_class' => Foo::class, + ]); + + $this->assertSame([ + '@context' => [ + '@vocab' => 'http://localhost:8080/docs.jsonld#', + 'hydra' => 'http://www.w3.org/ns/hydra/core#', + 'hydra:member' => [ + '@type' => '@id', + ], + ], + '@id' => '/foos', + '@type' => 'hydra:Collection', + 'hydra:member' => [ + '/foos/1', + '/foos/3', ], 'hydra:totalItems' => 2, ], $actual); diff --git a/tests/JsonLd/ContextBuilderTest.php b/tests/JsonLd/ContextBuilderTest.php index 4176bd5ee46..54103706cb8 100644 --- a/tests/JsonLd/ContextBuilderTest.php +++ b/tests/JsonLd/ContextBuilderTest.php @@ -70,6 +70,25 @@ public function testResourceContext() $this->assertEquals($expected, $contextBuilder->getResourceContext($this->entityClass)); } + public function testIriOnlyResourceContext() + { + $this->resourceMetadataFactoryProphecy->create($this->entityClass)->willReturn(new ResourceMetadata('DummyEntity', null, null, null, null, ['normalization_context' => ['iri_only' => true]])); + $this->propertyNameCollectionFactoryProphecy->create($this->entityClass)->willReturn(new PropertyNameCollection(['dummyPropertyA'])); + $this->propertyMetadataFactoryProphecy->create($this->entityClass, 'dummyPropertyA')->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING), 'Dummy property A', true, true, true, true, false, false)); + + $contextBuilder = new ContextBuilder($this->resourceNameCollectionFactoryProphecy->reveal(), $this->resourceMetadataFactoryProphecy->reveal(), $this->propertyNameCollectionFactoryProphecy->reveal(), $this->propertyMetadataFactoryProphecy->reveal(), $this->urlGeneratorProphecy->reveal()); + + $expected = [ + '@vocab' => '#', + 'hydra' => 'http://www.w3.org/ns/hydra/core#', + 'hydra:member' => [ + '@type' => '@id', + ], + ]; + + $this->assertEquals($expected, $contextBuilder->getResourceContext($this->entityClass)); + } + public function testResourceContextWithJsonldContext() { $this->resourceMetadataFactoryProphecy->create($this->entityClass)->willReturn(new ResourceMetadata('DummyEntity')); diff --git a/tests/Serializer/SerializerContextBuilderTest.php b/tests/Serializer/SerializerContextBuilderTest.php index 9c963e77233..a022a53454c 100644 --- a/tests/Serializer/SerializerContextBuilderTest.php +++ b/tests/Serializer/SerializerContextBuilderTest.php @@ -58,32 +58,32 @@ public function testCreateFromRequest() { $request = Request::create('/foos/1'); $request->attributes->replace(['_api_resource_class' => 'Foo', '_api_item_operation_name' => 'get', '_api_format' => 'xml', '_api_mime_type' => 'text/xml']); - $expected = ['foo' => 'bar', 'item_operation_name' => 'get', 'resource_class' => 'Foo', 'request_uri' => '/foos/1', 'operation_type' => 'item', 'uri' => 'http://localhost/foos/1', 'output' => null, 'input' => null]; + $expected = ['foo' => 'bar', 'item_operation_name' => 'get', 'resource_class' => 'Foo', 'request_uri' => '/foos/1', 'operation_type' => 'item', 'uri' => 'http://localhost/foos/1', 'output' => null, 'input' => null, 'iri_only' => false]; $this->assertEquals($expected, $this->builder->createFromRequest($request, true)); $request = Request::create('/foos'); $request->attributes->replace(['_api_resource_class' => 'Foo', '_api_collection_operation_name' => 'pot', '_api_format' => 'xml', '_api_mime_type' => 'text/xml']); - $expected = ['foo' => 'bar', 'collection_operation_name' => 'pot', 'resource_class' => 'Foo', 'request_uri' => '/foos', 'operation_type' => 'collection', 'uri' => 'http://localhost/foos', 'output' => null, 'input' => null]; + $expected = ['foo' => 'bar', 'collection_operation_name' => 'pot', 'resource_class' => 'Foo', 'request_uri' => '/foos', 'operation_type' => 'collection', 'uri' => 'http://localhost/foos', 'output' => null, 'input' => null, 'iri_only' => false]; $this->assertEquals($expected, $this->builder->createFromRequest($request, true)); $request = Request::create('/foos/1'); $request->attributes->replace(['_api_resource_class' => 'Foo', '_api_item_operation_name' => 'get', '_api_format' => 'xml', '_api_mime_type' => 'text/xml']); - $expected = ['bar' => 'baz', 'item_operation_name' => 'get', 'resource_class' => 'Foo', 'request_uri' => '/foos/1', 'api_allow_update' => false, 'operation_type' => 'item', 'uri' => 'http://localhost/foos/1', 'output' => null, 'input' => null]; + $expected = ['bar' => 'baz', 'item_operation_name' => 'get', 'resource_class' => 'Foo', 'request_uri' => '/foos/1', 'api_allow_update' => false, 'operation_type' => 'item', 'uri' => 'http://localhost/foos/1', 'output' => null, 'input' => null, 'iri_only' => false]; $this->assertEquals($expected, $this->builder->createFromRequest($request, false)); $request = Request::create('/foos', 'POST'); $request->attributes->replace(['_api_resource_class' => 'Foo', '_api_collection_operation_name' => 'post', '_api_format' => 'xml', '_api_mime_type' => 'text/xml']); - $expected = ['bar' => 'baz', 'collection_operation_name' => 'post', 'resource_class' => 'Foo', 'request_uri' => '/foos', 'api_allow_update' => false, 'operation_type' => 'collection', 'uri' => 'http://localhost/foos', 'output' => null, 'input' => null]; + $expected = ['bar' => 'baz', 'collection_operation_name' => 'post', 'resource_class' => 'Foo', 'request_uri' => '/foos', 'api_allow_update' => false, 'operation_type' => 'collection', 'uri' => 'http://localhost/foos', 'output' => null, 'input' => null, 'iri_only' => false]; $this->assertEquals($expected, $this->builder->createFromRequest($request, false)); $request = Request::create('/foos', 'PUT'); $request->attributes->replace(['_api_resource_class' => 'Foo', '_api_collection_operation_name' => 'put', '_api_format' => 'xml', '_api_mime_type' => 'text/xml']); - $expected = ['bar' => 'baz', 'collection_operation_name' => 'put', 'resource_class' => 'Foo', 'request_uri' => '/foos', 'api_allow_update' => true, 'operation_type' => 'collection', 'uri' => 'http://localhost/foos', 'output' => null, 'input' => null]; + $expected = ['bar' => 'baz', 'collection_operation_name' => 'put', 'resource_class' => 'Foo', 'request_uri' => '/foos', 'api_allow_update' => true, 'operation_type' => 'collection', 'uri' => 'http://localhost/foos', 'output' => null, 'input' => null, 'iri_only' => false]; $this->assertEquals($expected, $this->builder->createFromRequest($request, false)); $request = Request::create('/bars/1/foos'); $request->attributes->replace(['_api_resource_class' => 'Foo', '_api_subresource_operation_name' => 'get', '_api_format' => 'xml', '_api_mime_type' => 'text/xml']); - $expected = ['bar' => 'baz', 'subresource_operation_name' => 'get', 'resource_class' => 'Foo', 'request_uri' => '/bars/1/foos', 'operation_type' => 'subresource', 'api_allow_update' => false, 'uri' => 'http://localhost/bars/1/foos', 'output' => null, 'input' => null]; + $expected = ['bar' => 'baz', 'subresource_operation_name' => 'get', 'resource_class' => 'Foo', 'request_uri' => '/bars/1/foos', 'operation_type' => 'subresource', 'api_allow_update' => false, 'uri' => 'http://localhost/bars/1/foos', 'output' => null, 'input' => null, 'iri_only' => false]; $this->assertEquals($expected, $this->builder->createFromRequest($request, false)); } @@ -96,7 +96,7 @@ public function testThrowExceptionOnInvalidRequest() public function testReuseExistingAttributes() { - $expected = ['bar' => 'baz', 'item_operation_name' => 'get', 'resource_class' => 'Foo', 'request_uri' => '/foos/1', 'api_allow_update' => false, 'operation_type' => 'item', 'uri' => 'http://localhost/foos/1', 'output' => null, 'input' => null]; + $expected = ['bar' => 'baz', 'item_operation_name' => 'get', 'resource_class' => 'Foo', 'request_uri' => '/foos/1', 'api_allow_update' => false, 'operation_type' => 'item', 'uri' => 'http://localhost/foos/1', 'output' => null, 'input' => null, 'iri_only' => false]; $this->assertEquals($expected, $this->builder->createFromRequest(Request::create('/foos/1'), false, ['resource_class' => 'Foo', 'item_operation_name' => 'get'])); } } From 8c69482aef9be970b29cd13e523399dcda1e78c9 Mon Sep 17 00:00:00 2001 From: Matyas Somfai Date: Thu, 15 Oct 2020 11:20:56 +0200 Subject: [PATCH 123/160] Add configurable asset_package Fix unit tests cs fix minor fixes --- .../Symfony/Bundle/Action/SwaggerUiAction.php | 5 ++++- .../ApiPlatformExtension.php | 1 + .../DependencyInjection/Configuration.php | 1 + .../Bundle/Resources/config/swagger-ui.xml | 2 ++ .../Resources/views/SwaggerUi/index.html.twig | 22 +++++++++---------- .../Bundle/SwaggerUi/SwaggerUiAction.php | 1 + .../Bundle/SwaggerUi/SwaggerUiContext.php | 9 +++++++- .../Bundle/Action/SwaggerUiActionTest.php | 1 + .../ApiPlatformExtensionTest.php | 2 ++ .../DependencyInjection/ConfigurationTest.php | 1 + .../Bundle/SwaggerUi/SwaggerUiActionTest.php | 1 + 11 files changed, 33 insertions(+), 13 deletions(-) diff --git a/src/Bridge/Symfony/Bundle/Action/SwaggerUiAction.php b/src/Bridge/Symfony/Bundle/Action/SwaggerUiAction.php index 654736f229c..523369f3740 100644 --- a/src/Bridge/Symfony/Bundle/Action/SwaggerUiAction.php +++ b/src/Bridge/Symfony/Bundle/Action/SwaggerUiAction.php @@ -61,11 +61,12 @@ final class SwaggerUiAction private $graphQlPlaygroundEnabled; private $swaggerVersions; private $swaggerUiAction; + private $assetPackage; /** * @param int[] $swaggerVersions */ - public function __construct(ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, ResourceMetadataFactoryInterface $resourceMetadataFactory, NormalizerInterface $normalizer, TwigEnvironment $twig, UrlGeneratorInterface $urlGenerator, string $title = '', string $description = '', string $version = '', $formats = [], $oauthEnabled = false, $oauthClientId = '', $oauthClientSecret = '', $oauthType = '', $oauthFlow = '', $oauthTokenUrl = '', $oauthAuthorizationUrl = '', $oauthScopes = [], bool $showWebby = true, bool $swaggerUiEnabled = false, bool $reDocEnabled = false, bool $graphqlEnabled = false, bool $graphiQlEnabled = false, bool $graphQlPlaygroundEnabled = false, array $swaggerVersions = [2, 3], OpenApiSwaggerUiAction $swaggerUiAction = null) + public function __construct(ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, ResourceMetadataFactoryInterface $resourceMetadataFactory, NormalizerInterface $normalizer, TwigEnvironment $twig, UrlGeneratorInterface $urlGenerator, string $title = '', string $description = '', string $version = '', $formats = [], $oauthEnabled = false, $oauthClientId = '', $oauthClientSecret = '', $oauthType = '', $oauthFlow = '', $oauthTokenUrl = '', $oauthAuthorizationUrl = '', $oauthScopes = [], bool $showWebby = true, bool $swaggerUiEnabled = false, bool $reDocEnabled = false, bool $graphqlEnabled = false, bool $graphiQlEnabled = false, bool $graphQlPlaygroundEnabled = false, array $swaggerVersions = [2, 3], OpenApiSwaggerUiAction $swaggerUiAction = null, $assetPackage = null) { $this->resourceNameCollectionFactory = $resourceNameCollectionFactory; $this->resourceMetadataFactory = $resourceMetadataFactory; @@ -91,6 +92,7 @@ public function __construct(ResourceNameCollectionFactoryInterface $resourceName $this->graphQlPlaygroundEnabled = $graphQlPlaygroundEnabled; $this->swaggerVersions = $swaggerVersions; $this->swaggerUiAction = $swaggerUiAction; + $this->assetPackage = $assetPackage; if (null === $this->swaggerUiAction) { @trigger_error(sprintf('The use of "%s" is deprecated since API Platform 2.6, use "%s" instead.', __CLASS__, OpenApiSwaggerUiAction::class), E_USER_DEPRECATED); @@ -143,6 +145,7 @@ private function getContext(Request $request, Documentation $documentation): arr 'graphqlEnabled' => $this->graphqlEnabled, 'graphiQlEnabled' => $this->graphiQlEnabled, 'graphQlPlaygroundEnabled' => $this->graphQlPlaygroundEnabled, + 'assetPackage' => $this->assetPackage, ]; $swaggerContext = ['spec_version' => $request->query->getInt('spec_version', $this->swaggerVersions[0] ?? 2)]; diff --git a/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index b7bf18a94d1..e0a432999e1 100644 --- a/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -217,6 +217,7 @@ private function registerCommonConfiguration(ContainerBuilder $container, array if ($config['name_converter']) { $container->setAlias('api_platform.name_converter', $config['name_converter']); } + $container->setParameter('api_platform.asset_package', $config['asset_package']); $container->setParameter('api_platform.defaults', $this->normalizeDefaults($config['defaults'] ?? [])); } diff --git a/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php b/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php index 51ca4471e2b..8ecbe699d96 100644 --- a/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php +++ b/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php @@ -92,6 +92,7 @@ public function getConfigTreeBuilder() ->info('Specify the default operation path resolver to use for generating resources operations path.') ->end() ->scalarNode('name_converter')->defaultNull()->info('Specify a name converter to use.')->end() + ->scalarNode('asset_package')->defaultNull()->info('Specify an asset package name to use.')->end() ->scalarNode('path_segment_name_generator')->defaultValue('api_platform.path_segment_name_generator.underscore')->info('Specify a path name generator to use.')->end() ->booleanNode('allow_plain_identifiers')->defaultFalse()->info('Allow plain identifiers, for example "id" instead of "@id" when denormalizing a relation.')->end() ->arrayNode('validator') diff --git a/src/Bridge/Symfony/Bundle/Resources/config/swagger-ui.xml b/src/Bridge/Symfony/Bundle/Resources/config/swagger-ui.xml index 2b7b80bd8f1..c56d3a3440c 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/swagger-ui.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/swagger-ui.xml @@ -37,6 +37,7 @@ %api_platform.graphql.graphql_playground.enabled% %api_platform.swagger.versions% + %api_platform.asset_package% @@ -46,6 +47,7 @@ %api_platform.graphql.enabled% %api_platform.graphql.graphiql.enabled% %api_platform.graphql.graphql_playground.enabled% + %api_platform.asset_package% diff --git a/src/Bridge/Symfony/Bundle/Resources/views/SwaggerUi/index.html.twig b/src/Bridge/Symfony/Bundle/Resources/views/SwaggerUi/index.html.twig index 88547ad5e0c..83fccc03799 100644 --- a/src/Bridge/Symfony/Bundle/Resources/views/SwaggerUi/index.html.twig +++ b/src/Bridge/Symfony/Bundle/Resources/views/SwaggerUi/index.html.twig @@ -6,11 +6,11 @@ {% block stylesheet %} - - + + {% endblock %} - {% set oauth_data = {'oauth': swagger_data.oauth|merge({'redirectUrl' : absolute_url(asset('bundles/apiplatform/swagger-ui/oauth2-redirect.html')) })} %} + {% set oauth_data = {'oauth': swagger_data.oauth|merge({'redirectUrl' : absolute_url(asset('bundles/apiplatform/swagger-ui/oauth2-redirect.html', assetPackage)) })} %} {# json_encode(65) is for JSON_UNESCAPED_SLASHES|JSON_HEX_TAG to avoid JS XSS #} @@ -50,12 +50,12 @@
- +
{% if showWebby %} -
-
+
+
{% endif %}
@@ -82,12 +82,12 @@ {% block javascript %} {% if (reDocEnabled and not swaggerUiEnabled) or (reDocEnabled and 're_doc' == active_ui) %} - - + + {% else %} - - - + + + {% endif %} {% endblock %} diff --git a/src/Bridge/Symfony/Bundle/SwaggerUi/SwaggerUiAction.php b/src/Bridge/Symfony/Bundle/SwaggerUi/SwaggerUiAction.php index ca41097141e..1767e56f814 100644 --- a/src/Bridge/Symfony/Bundle/SwaggerUi/SwaggerUiAction.php +++ b/src/Bridge/Symfony/Bundle/SwaggerUi/SwaggerUiAction.php @@ -70,6 +70,7 @@ public function __invoke(Request $request) 'graphqlEnabled' => $this->swaggerUiContext->isGraphQlEnabled(), 'graphiQlEnabled' => $this->swaggerUiContext->isGraphiQlEnabled(), 'graphQlPlaygroundEnabled' => $this->swaggerUiContext->isGraphQlPlaygroundEnabled(), + 'assetPackage' => $this->swaggerUiContext->getAssetPackage(), ]; $swaggerData = [ diff --git a/src/Bridge/Symfony/Bundle/SwaggerUi/SwaggerUiContext.php b/src/Bridge/Symfony/Bundle/SwaggerUi/SwaggerUiContext.php index 9586ad3287f..29aaf205600 100644 --- a/src/Bridge/Symfony/Bundle/SwaggerUi/SwaggerUiContext.php +++ b/src/Bridge/Symfony/Bundle/SwaggerUi/SwaggerUiContext.php @@ -21,8 +21,9 @@ final class SwaggerUiContext private $graphQlEnabled; private $graphiQlEnabled; private $graphQlPlaygroundEnabled; + private $assetPackage; - public function __construct(bool $swaggerUiEnabled = false, bool $showWebby = true, bool $reDocEnabled = false, bool $graphQlEnabled = false, bool $graphiQlEnabled = false, bool $graphQlPlaygroundEnabled = false) + public function __construct(bool $swaggerUiEnabled = false, bool $showWebby = true, bool $reDocEnabled = false, bool $graphQlEnabled = false, bool $graphiQlEnabled = false, bool $graphQlPlaygroundEnabled = false, $assetPackage = null) { $this->swaggerUiEnabled = $swaggerUiEnabled; $this->showWebby = $showWebby; @@ -30,6 +31,7 @@ public function __construct(bool $swaggerUiEnabled = false, bool $showWebby = tr $this->graphQlEnabled = $graphQlEnabled; $this->graphiQlEnabled = $graphiQlEnabled; $this->graphQlPlaygroundEnabled = $graphQlPlaygroundEnabled; + $this->assetPackage = $assetPackage; } public function isSwaggerUiEnabled(): bool @@ -61,4 +63,9 @@ public function isGraphQlPlaygroundEnabled(): bool { return $this->graphQlPlaygroundEnabled; } + + public function getAssetPackage(): ?string + { + return $this->assetPackage; + } } diff --git a/tests/Bridge/Symfony/Bundle/Action/SwaggerUiActionTest.php b/tests/Bridge/Symfony/Bundle/Action/SwaggerUiActionTest.php index 73ad02d47c2..2a5cfb7b1ac 100644 --- a/tests/Bridge/Symfony/Bundle/Action/SwaggerUiActionTest.php +++ b/tests/Bridge/Symfony/Bundle/Action/SwaggerUiActionTest.php @@ -174,6 +174,7 @@ public function testDoNotRunCurrentRequest(Request $request) 'graphqlEnabled' => false, 'graphiQlEnabled' => false, 'graphQlPlaygroundEnabled' => false, + 'assetPackage' => null, 'swagger_data' => [ 'url' => '/url', 'spec' => self::SPEC, diff --git a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php index 13198e2f1ea..15fa0b65102 100644 --- a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php +++ b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php @@ -872,6 +872,7 @@ private function getPartialContainerBuilderProphecy($configuration = null) 'api_platform.http_cache.vary' => ['Accept'], 'api_platform.http_cache.public' => null, 'api_platform.http_cache.invalidation.max_header_length' => 7500, + 'api_platform.asset_package' => null, 'api_platform.defaults' => ['attributes' => ['stateless' => true]], 'api_platform.enable_entrypoint' => true, 'api_platform.enable_docs' => true, @@ -1176,6 +1177,7 @@ private function getBaseContainerBuilderProphecy(array $doctrineIntegrationsToLo 'api_platform.resource_class_directories' => Argument::type('array'), 'api_platform.validator.serialize_payload_fields' => [], 'api_platform.elasticsearch.enabled' => false, + 'api_platform.asset_package' => null, 'api_platform.defaults' => ['attributes' => ['stateless' => true]], ]; diff --git a/tests/Bridge/Symfony/Bundle/DependencyInjection/ConfigurationTest.php b/tests/Bridge/Symfony/Bundle/DependencyInjection/ConfigurationTest.php index 64ea917210b..fd4c045d233 100644 --- a/tests/Bridge/Symfony/Bundle/DependencyInjection/ConfigurationTest.php +++ b/tests/Bridge/Symfony/Bundle/DependencyInjection/ConfigurationTest.php @@ -206,6 +206,7 @@ private function runDefaultConfigTests(array $doctrineIntegrationsToLoad = ['orm ], 'allow_plain_identifiers' => false, 'resource_class_directories' => [], + 'asset_package' => null, ], $config); } diff --git a/tests/Bridge/Symfony/Bundle/SwaggerUi/SwaggerUiActionTest.php b/tests/Bridge/Symfony/Bundle/SwaggerUi/SwaggerUiActionTest.php index 1a231b883c7..1990b008196 100644 --- a/tests/Bridge/Symfony/Bundle/SwaggerUi/SwaggerUiActionTest.php +++ b/tests/Bridge/Symfony/Bundle/SwaggerUi/SwaggerUiActionTest.php @@ -169,6 +169,7 @@ public function testDoNotRunCurrentRequest(Request $request) 'graphqlEnabled' => false, 'graphiQlEnabled' => false, 'graphQlPlaygroundEnabled' => false, + 'assetPackage' => null, 'swagger_data' => [ 'url' => '/url', 'spec' => self::SPEC, From b5c72b78a05b504d841b82d4567c1708954fe185 Mon Sep 17 00:00:00 2001 From: Alan Poulain Date: Tue, 20 Oct 2020 21:38:54 +0200 Subject: [PATCH 124/160] [Mercure] Add a `normalization_context` option in `mercure` attribute (#3772) * chore: bump PHPUnit and phpDocumentor * feat: add a normalization_context option for mercure attribute --- .github/workflows/ci.yml | 4 ++-- composer.json | 4 ++-- .../PublishMercureUpdatesListener.php | 3 ++- .../PublishMercureUpdatesListenerTest.php | 14 ++++++++++---- 4 files changed, 16 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a544b6d5b3c..5b4aab59aa6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -122,7 +122,7 @@ jobs: composer update --no-progress --no-suggest --ansi - name: Install PHPUnit env: - SYMFONY_PHPUNIT_VERSION: '9.2' + SYMFONY_PHPUNIT_VERSION: '9.4' run: vendor/bin/simple-phpunit --version - name: Clear test app cache run: | @@ -140,7 +140,7 @@ jobs: continue-on-error: true - name: Run PHPStan analysis env: - SYMFONY_PHPUNIT_VERSION: '9.2' + SYMFONY_PHPUNIT_VERSION: '9.4' run: vendor/bin/phpstan analyse --no-progress --no-interaction --ansi phpunit: diff --git a/composer.json b/composer.json index d6d855f118f..851b26af9bb 100644 --- a/composer.json +++ b/composer.json @@ -47,8 +47,8 @@ "jangregor/phpstan-prophecy": "^0.6", "justinrainbow/json-schema": "^5.2.1", "nelmio/api-doc-bundle": "^2.13.4", - "phpdocumentor/reflection-docblock": "^3.0 || ^4.0", - "phpdocumentor/type-resolver": "^0.3 || ^0.4", + "phpdocumentor/reflection-docblock": "^3.0 || ^4.0 || ^5.0", + "phpdocumentor/type-resolver": "^0.3 || ^0.4 || ^0.5 || ^0.6 || ^0.7 || ^1.0", "phpstan/extension-installer": "^1.0", "phpstan/phpstan": "^0.12.4", "phpstan/phpstan-doctrine": "^0.12.7", diff --git a/src/Bridge/Doctrine/EventListener/PublishMercureUpdatesListener.php b/src/Bridge/Doctrine/EventListener/PublishMercureUpdatesListener.php index 4995f65a61d..7fd2ff183ba 100644 --- a/src/Bridge/Doctrine/EventListener/PublishMercureUpdatesListener.php +++ b/src/Bridge/Doctrine/EventListener/PublishMercureUpdatesListener.php @@ -48,6 +48,7 @@ final class PublishMercureUpdatesListener 'id' => true, 'type' => true, 'retry' => true, + 'normalization_context' => true, ]; use DispatchTrait; @@ -215,7 +216,7 @@ private function publishUpdate($object, array $options, string $type): void $data = $options['data'] ?? json_encode(['@id' => $object->id]); } else { $resourceClass = $this->getObjectClass($object); - $context = $this->resourceMetadataFactory->create($resourceClass)->getAttribute('normalization_context', []); + $context = $options['normalization_context'] ?? $this->resourceMetadataFactory->create($resourceClass)->getAttribute('normalization_context', []); $iri = $options['topics'] ?? $this->iriConverter->getIriFromItem($object, UrlGeneratorInterface::ABS_URL); $data = $options['data'] ?? $this->serializer->serialize($object, key($this->formats), $context); diff --git a/tests/Bridge/Doctrine/EventListener/PublishMercureUpdatesListenerTest.php b/tests/Bridge/Doctrine/EventListener/PublishMercureUpdatesListenerTest.php index f0dba0efee9..ce444ed6e1b 100644 --- a/tests/Bridge/Doctrine/EventListener/PublishMercureUpdatesListenerTest.php +++ b/tests/Bridge/Doctrine/EventListener/PublishMercureUpdatesListenerTest.php @@ -26,6 +26,7 @@ use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyCar; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyFriend; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyOffer; use ApiPlatform\Core\Tests\ProphecyTrait; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\Event\OnFlushEventArgs; @@ -137,6 +138,7 @@ public function testPublishUpdate(): void $toUpdate = new Dummy(); $toUpdate->setId(2); $toUpdateNoMercureAttribute = new DummyCar(); + $toUpdateMercureOptions = new DummyOffer(); $toDelete = new Dummy(); $toDelete->setId(3); @@ -147,10 +149,12 @@ public function testPublishUpdate(): void $resourceClassResolverProphecy->getResourceClass(Argument::type(Dummy::class))->willReturn(Dummy::class); $resourceClassResolverProphecy->getResourceClass(Argument::type(DummyCar::class))->willReturn(DummyCar::class); $resourceClassResolverProphecy->getResourceClass(Argument::type(DummyFriend::class))->willReturn(DummyFriend::class); + $resourceClassResolverProphecy->getResourceClass(Argument::type(DummyOffer::class))->willReturn(DummyOffer::class); $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); $resourceClassResolverProphecy->isResourceClass(NotAResource::class)->willReturn(false); $resourceClassResolverProphecy->isResourceClass(DummyCar::class)->willReturn(true); $resourceClassResolverProphecy->isResourceClass(DummyFriend::class)->willReturn(true); + $resourceClassResolverProphecy->isResourceClass(DummyOffer::class)->willReturn(true); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); $iriConverterProphecy->getIriFromItem($toInsert, UrlGeneratorInterface::ABS_URL)->willReturn('http://example.com/dummies/1')->shouldBeCalled(); @@ -164,10 +168,12 @@ public function testPublishUpdate(): void $resourceMetadataFactoryProphecy->create(Dummy::class)->willReturn(new ResourceMetadata(null, null, null, null, null, ['mercure' => true, 'normalization_context' => ['groups' => ['foo', 'bar']]])); $resourceMetadataFactoryProphecy->create(DummyCar::class)->willReturn(new ResourceMetadata()); $resourceMetadataFactoryProphecy->create(DummyFriend::class)->willReturn(new ResourceMetadata(null, null, null, null, null, ['mercure' => ['private' => true, 'retry' => 10]])); + $resourceMetadataFactoryProphecy->create(DummyOffer::class)->willReturn(new ResourceMetadata(null, null, null, null, null, ['mercure' => ['topics' => 'http://example.com/custom_topics/1', 'normalization_context' => ['groups' => ['baz']]]])); $serializerProphecy = $this->prophesize(SerializerInterface::class); $serializerProphecy->serialize($toInsert, 'jsonld', ['groups' => ['foo', 'bar']])->willReturn('1'); $serializerProphecy->serialize($toUpdate, 'jsonld', ['groups' => ['foo', 'bar']])->willReturn('2'); + $serializerProphecy->serialize($toUpdateMercureOptions, 'jsonld', ['groups' => ['baz']])->willReturn('mercure_options'); $formats = ['jsonld' => ['application/ld+json'], 'jsonhal' => ['application/hal+json']]; @@ -194,7 +200,7 @@ public function testPublishUpdate(): void $uowProphecy = $this->prophesize(UnitOfWork::class); $uowProphecy->getScheduledEntityInsertions()->willReturn([$toInsert, $toInsertNotResource])->shouldBeCalled(); - $uowProphecy->getScheduledEntityUpdates()->willReturn([$toUpdate, $toUpdateNoMercureAttribute])->shouldBeCalled(); + $uowProphecy->getScheduledEntityUpdates()->willReturn([$toUpdate, $toUpdateNoMercureAttribute, $toUpdateMercureOptions])->shouldBeCalled(); $uowProphecy->getScheduledEntityDeletions()->willReturn([$toDelete, $toDeleteExpressionLanguage])->shouldBeCalled(); $emProphecy = $this->prophesize(EntityManagerInterface::class); @@ -204,9 +210,9 @@ public function testPublishUpdate(): void $listener->onFlush($eventArgs); $listener->postFlush(); - $this->assertSame(['http://example.com/dummies/1', 'http://example.com/dummies/2', 'http://example.com/dummies/3', 'http://example.com/dummy_friends/4'], $topics); - $this->assertSame([false, false, false, true], $private); - $this->assertSame([null, null, null, 10], $retry); + $this->assertSame(['http://example.com/dummies/1', 'http://example.com/dummies/2', 'http://example.com/custom_topics/1', 'http://example.com/dummies/3', 'http://example.com/dummy_friends/4'], $topics); + $this->assertSame([false, false, false, false, true], $private); + $this->assertSame([null, null, null, null, 10], $retry); } public function testPublishGraphQlUpdates(): void From ff931eb564fe11113f83063c6e11be528ac36fc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois-Xavier=20de=20Guillebon?= Date: Wed, 28 Oct 2020 16:14:46 +0100 Subject: [PATCH 125/160] Lazy load remaining commands --- src/Bridge/Symfony/Bundle/Command/OpenApiCommand.php | 3 ++- src/Bridge/Symfony/Bundle/Command/SwaggerCommand.php | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Bridge/Symfony/Bundle/Command/OpenApiCommand.php b/src/Bridge/Symfony/Bundle/Command/OpenApiCommand.php index 6bc69f52a86..b89925b0c3d 100644 --- a/src/Bridge/Symfony/Bundle/Command/OpenApiCommand.php +++ b/src/Bridge/Symfony/Bundle/Command/OpenApiCommand.php @@ -29,6 +29,8 @@ */ final class OpenApiCommand extends Command { + protected static $defaultName = 'api:openapi:export'; + private $openApiFactory; private $normalizer; @@ -45,7 +47,6 @@ public function __construct(OpenApiFactoryInterface $openApiFactory, NormalizerI protected function configure() { $this - ->setName('api:openapi:export') ->setDescription('Dump the Open API documentation') ->addOption('yaml', 'y', InputOption::VALUE_NONE, 'Dump the documentation in YAML') ->addOption('output', 'o', InputOption::VALUE_OPTIONAL, 'Write output to file') diff --git a/src/Bridge/Symfony/Bundle/Command/SwaggerCommand.php b/src/Bridge/Symfony/Bundle/Command/SwaggerCommand.php index 02dfbe45006..ebb80d512fc 100644 --- a/src/Bridge/Symfony/Bundle/Command/SwaggerCommand.php +++ b/src/Bridge/Symfony/Bundle/Command/SwaggerCommand.php @@ -33,6 +33,8 @@ */ final class SwaggerCommand extends Command { + protected static $defaultName = 'api:swagger:export'; + private $normalizer; private $resourceNameCollectionFactory; private $apiTitle; @@ -67,7 +69,6 @@ public function __construct(NormalizerInterface $normalizer, ResourceNameCollect protected function configure() { $this - ->setName('api:swagger:export') ->setDescription('Dump the Swagger v2 documentation') ->addOption('yaml', 'y', InputOption::VALUE_NONE, 'Dump the documentation in YAML') ->addOption('spec-version', null, InputOption::VALUE_OPTIONAL, sprintf('OpenAPI version to use (%s)', implode(' or ', $this->swaggerVersions)), $this->swaggerVersions[0] ?? 2) From 9b2ce68c0b98d9379364cf0888b8941f3a10ac09 Mon Sep 17 00:00:00 2001 From: Vincent Chalamon Date: Thu, 29 Oct 2020 17:51:39 +0100 Subject: [PATCH 126/160] Do not inject api_platform.security.resource_access_checker on ItemNormalizer if SecurityBundle is not installed --- src/Bridge/Symfony/Bundle/Resources/config/api.xml | 2 +- src/Bridge/Symfony/Bundle/Resources/config/hal.xml | 2 +- src/Bridge/Symfony/Bundle/Resources/config/jsonapi.xml | 2 +- src/Bridge/Symfony/Bundle/Resources/config/jsonld.xml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Bridge/Symfony/Bundle/Resources/config/api.xml b/src/Bridge/Symfony/Bundle/Resources/config/api.xml index ffa00199f6d..ad628a51206 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/api.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/api.xml @@ -114,7 +114,7 @@ null - + diff --git a/src/Bridge/Symfony/Bundle/Resources/config/hal.xml b/src/Bridge/Symfony/Bundle/Resources/config/hal.xml index 9ba48f25be3..ac713781345 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/hal.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/hal.xml @@ -41,7 +41,7 @@ - + diff --git a/src/Bridge/Symfony/Bundle/Resources/config/jsonapi.xml b/src/Bridge/Symfony/Bundle/Resources/config/jsonapi.xml index 858b30e30d9..2c2203cbad5 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/jsonapi.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/jsonapi.xml @@ -42,7 +42,7 @@ - + diff --git a/src/Bridge/Symfony/Bundle/Resources/config/jsonld.xml b/src/Bridge/Symfony/Bundle/Resources/config/jsonld.xml index 3912ad1e1b4..7c1715f9e67 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/jsonld.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/jsonld.xml @@ -27,7 +27,7 @@ - + From 70289ca1ea27a38eaf9bd7f3bef63ac62ba03d7f Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 6 Nov 2020 15:22:20 +0100 Subject: [PATCH 127/160] Fix tests --- composer.json | 6 +++--- .../Bundle/SwaggerUi/SwaggerUiAction.php | 2 +- tests/Behat/JsonApiContext.php | 2 +- .../DoctrineOrmPropertyMetadataFactoryTest.php | 6 +++--- .../Bundle/Action/SwaggerUiActionTest.php | 6 +++--- .../Bundle/SwaggerUi/SwaggerUiActionTest.php | 18 +++++++++--------- tests/Fixtures/app/config/config_common.yml | 1 - tests/Hydra/JsonSchema/SchemaFactoryTest.php | 5 +++-- tests/OpenApi/Factory/OpenApiFactoryTest.php | 1 + 9 files changed, 24 insertions(+), 23 deletions(-) diff --git a/composer.json b/composer.json index b663fec3bae..3e49a1aa80d 100644 --- a/composer.json +++ b/composer.json @@ -29,9 +29,6 @@ "require-dev": { "behat/behat": "^3.1", "behat/mink": "^1.7", - "friends-of-behat/mink-browserkit-driver": "^1.3.1", - "friends-of-behat/mink-extension": "^2.2", - "friends-of-behat/symfony-extension": "^2.1", "behatch/contexts": "dev-api-platform", "doctrine/annotations": "^1.7", "doctrine/common": "^2.11 || ^3.0", @@ -41,6 +38,9 @@ "doctrine/mongodb-odm-bundle": "^4.0", "doctrine/orm": "^2.6.4 || ^3.0", "elasticsearch/elasticsearch": "^6.0 || ^7.0", + "friends-of-behat/mink-browserkit-driver": "^1.3.1", + "friends-of-behat/mink-extension": "^2.2", + "friends-of-behat/symfony-extension": "^2.1", "guzzlehttp/guzzle": "^6.0 || ^7.0", "jangregor/phpstan-prophecy": "^0.8", "justinrainbow/json-schema": "^5.2.1", diff --git a/src/Bridge/Symfony/Bundle/SwaggerUi/SwaggerUiAction.php b/src/Bridge/Symfony/Bundle/SwaggerUi/SwaggerUiAction.php index ca41097141e..cea0744512e 100644 --- a/src/Bridge/Symfony/Bundle/SwaggerUi/SwaggerUiAction.php +++ b/src/Bridge/Symfony/Bundle/SwaggerUi/SwaggerUiAction.php @@ -112,7 +112,7 @@ private function getPathAndMethod(array $swaggerData): array { foreach ($swaggerData['spec']['paths'] as $path => $operations) { foreach ($operations as $method => $operation) { - if ($operation['operationId'] ?? null === $swaggerData['operationId']) { + if (($operation['operationId'] ?? null) === $swaggerData['operationId']) { return [$path, $method]; } } diff --git a/tests/Behat/JsonApiContext.php b/tests/Behat/JsonApiContext.php index 4bd0c80a1c4..2c3699b9c30 100644 --- a/tests/Behat/JsonApiContext.php +++ b/tests/Behat/JsonApiContext.php @@ -123,7 +123,7 @@ public function theJsonNodeShouldBeSorted($node = '') { $actual = (array) $this->getValueOfNode($node); - if (!is_array($actual)) { + if (!\is_array($actual)) { throw new \Exception(sprintf('The "%s" node value is not an array', $node)); } diff --git a/tests/Bridge/Doctrine/Orm/Metadata/Property/DoctrineOrmPropertyMetadataFactoryTest.php b/tests/Bridge/Doctrine/Orm/Metadata/Property/DoctrineOrmPropertyMetadataFactoryTest.php index aead537d5e3..ca9fbe6a3eb 100644 --- a/tests/Bridge/Doctrine/Orm/Metadata/Property/DoctrineOrmPropertyMetadataFactoryTest.php +++ b/tests/Bridge/Doctrine/Orm/Metadata/Property/DoctrineOrmPropertyMetadataFactoryTest.php @@ -19,10 +19,10 @@ use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyPropertyWithDefaultValue; use ApiPlatform\Core\Tests\ProphecyTrait; -use Doctrine\Common\Persistence\ManagerRegistry; -use Doctrine\Common\Persistence\Mapping\ClassMetadata; -use Doctrine\Common\Persistence\ObjectManager; use Doctrine\ORM\Mapping\ClassMetadataInfo; +use Doctrine\Persistence\ManagerRegistry; +use Doctrine\Persistence\Mapping\ClassMetadata; +use Doctrine\Persistence\ObjectManager; use PHPUnit\Framework\TestCase; /** diff --git a/tests/Bridge/Symfony/Bundle/Action/SwaggerUiActionTest.php b/tests/Bridge/Symfony/Bundle/Action/SwaggerUiActionTest.php index 73ad02d47c2..097369c992c 100644 --- a/tests/Bridge/Symfony/Bundle/Action/SwaggerUiActionTest.php +++ b/tests/Bridge/Symfony/Bundle/Action/SwaggerUiActionTest.php @@ -105,7 +105,7 @@ public function getInvokeParameters() 'path' => '/fs', 'method' => 'get', ], - ])->shouldBeCalled(); + ])->shouldBeCalled()->willReturn(''); $twigItemProphecy = $this->prophesize(TwigEnvironment::class); $twigItemProphecy->render('@ApiPlatform/SwaggerUi/index.html.twig', [ @@ -138,7 +138,7 @@ public function getInvokeParameters() 'path' => '/fs/{id}', 'method' => 'get', ], - ])->shouldBeCalled(); + ])->shouldBeCalled()->willReturn(''); return [ [new Request([], [], ['_api_resource_class' => 'Foo', '_api_collection_operation_name' => 'get']), $twigCollectionProphecy], @@ -188,7 +188,7 @@ public function testDoNotRunCurrentRequest(Request $request) 'scopes' => [], ], ], - ])->shouldBeCalled(); + ])->shouldBeCalled()->willReturn(''); $urlGeneratorProphecy = $this->prophesize(UrlGenerator::class); $urlGeneratorProphecy->generate('api_doc', ['format' => 'json'])->willReturn('/url')->shouldBeCalled(); diff --git a/tests/Bridge/Symfony/Bundle/SwaggerUi/SwaggerUiActionTest.php b/tests/Bridge/Symfony/Bundle/SwaggerUi/SwaggerUiActionTest.php index 1a231b883c7..697d059e7af 100644 --- a/tests/Bridge/Symfony/Bundle/SwaggerUi/SwaggerUiActionTest.php +++ b/tests/Bridge/Symfony/Bundle/SwaggerUi/SwaggerUiActionTest.php @@ -76,9 +76,9 @@ public function getInvokeParameters() { $twigCollectionProphecy = $this->prophesize(TwigEnvironment::class); $twigCollectionProphecy->render('@ApiPlatform/SwaggerUi/index.html.twig', [ - 'title' => '', + 'title' => 'title', 'description' => '', - 'formats' => [], + 'formats' => ['jsonld' => ['application/ld+json']], 'showWebby' => true, 'swaggerUiEnabled' => false, 'reDocEnabled' => false, @@ -105,13 +105,13 @@ public function getInvokeParameters() 'path' => '/fs', 'method' => 'get', ], - ])->shouldBeCalled(); + ])->shouldBeCalled()->willReturn(''); $twigItemProphecy = $this->prophesize(TwigEnvironment::class); $twigItemProphecy->render('@ApiPlatform/SwaggerUi/index.html.twig', [ - 'title' => '', + 'title' => 'title', 'description' => '', - 'formats' => [], + 'formats' => ['jsonld' => ['application/ld+json']], 'swaggerUiEnabled' => false, 'showWebby' => true, 'reDocEnabled' => false, @@ -123,8 +123,8 @@ public function getInvokeParameters() 'spec' => self::SPEC, 'oauth' => [ 'enabled' => false, - 'clientId' => '', - 'clientSecret' => '', + 'clientId' => null, + 'clientSecret' => null, 'type' => '', 'flow' => '', 'tokenUrl' => '', @@ -138,7 +138,7 @@ public function getInvokeParameters() 'path' => '/fs/{id}', 'method' => 'get', ], - ])->shouldBeCalled(); + ])->shouldBeCalled()->willReturn(''); return [ [new Request([], [], ['_api_resource_class' => 'Foo', '_api_collection_operation_name' => 'get']), $twigCollectionProphecy], @@ -183,7 +183,7 @@ public function testDoNotRunCurrentRequest(Request $request) 'scopes' => [], ], ], - ])->shouldBeCalled(); + ])->shouldBeCalled()->willReturn(''); $urlGeneratorProphecy = $this->prophesize(UrlGenerator::class); $urlGeneratorProphecy->generate('api_doc', ['format' => 'json'])->willReturn('/url')->shouldBeCalled(); diff --git a/tests/Fixtures/app/config/config_common.yml b/tests/Fixtures/app/config/config_common.yml index f8b99a5b67e..9eb3b87cfdc 100644 --- a/tests/Fixtures/app/config/config_common.yml +++ b/tests/Fixtures/app/config/config_common.yml @@ -71,7 +71,6 @@ api_platform: client_items_per_page: true client_partial: true items_per_page: 3 ->>>>>>> upstream/2.5 exception_to_status: Symfony\Component\Serializer\Exception\ExceptionInterface: !php/const Symfony\Component\HttpFoundation\Response::HTTP_BAD_REQUEST ApiPlatform\Core\Exception\InvalidArgumentException: !php/const Symfony\Component\HttpFoundation\Response::HTTP_BAD_REQUEST diff --git a/tests/Hydra/JsonSchema/SchemaFactoryTest.php b/tests/Hydra/JsonSchema/SchemaFactoryTest.php index 057ab4bd97f..f80caf4dcb9 100644 --- a/tests/Hydra/JsonSchema/SchemaFactoryTest.php +++ b/tests/Hydra/JsonSchema/SchemaFactoryTest.php @@ -86,6 +86,7 @@ public function testHasRootDefinitionKeyBuildSchema(): void public function testSchemaTypeBuildSchema(): void { $resultSchema = $this->schemaFactory->buildSchema(Dummy::class, 'jsonld', Schema::TYPE_OUTPUT, OperationType::COLLECTION); + $definitionName = str_replace('\\', '.', Dummy::class).'.jsonld'; $this->assertNull($resultSchema->getRootDefinitionKey()); $this->assertArrayHasKey('properties', $resultSchema); @@ -93,7 +94,7 @@ public function testSchemaTypeBuildSchema(): void $this->assertArrayHasKey('hydra:totalItems', $resultSchema['properties']); $this->assertArrayHasKey('hydra:view', $resultSchema['properties']); $this->assertArrayHasKey('hydra:search', $resultSchema['properties']); - $properties = $resultSchema['definitions'][Dummy::class.':jsonld']['properties']; + $properties = $resultSchema['definitions'][$definitionName]['properties']; $this->assertArrayNotHasKey('@context', $properties); $this->assertArrayHasKey('@type', $properties); $this->assertArrayHasKey('@id', $properties); @@ -106,7 +107,7 @@ public function testSchemaTypeBuildSchema(): void $this->assertArrayHasKey('hydra:totalItems', $resultSchema['properties']); $this->assertArrayHasKey('hydra:view', $resultSchema['properties']); $this->assertArrayHasKey('hydra:search', $resultSchema['properties']); - $properties = $resultSchema['definitions'][Dummy::class.':jsonld']['properties']; + $properties = $resultSchema['definitions'][$definitionName]['properties']; $this->assertArrayNotHasKey('@context', $properties); $this->assertArrayHasKey('@type', $properties); $this->assertArrayHasKey('@id', $properties); diff --git a/tests/OpenApi/Factory/OpenApiFactoryTest.php b/tests/OpenApi/Factory/OpenApiFactoryTest.php index f960dc3a113..3fc49466d86 100644 --- a/tests/OpenApi/Factory/OpenApiFactoryTest.php +++ b/tests/OpenApi/Factory/OpenApiFactoryTest.php @@ -177,6 +177,7 @@ public function testInvoke(): void $dummySchema->setDefinitions(new \ArrayObject([ 'type' => 'object', 'description' => 'This is a dummy.', + 'additionalProperties' => false, 'properties' => [ 'id' => new \ArrayObject([ 'type' => 'integer', From 6ad1fbc193542edfbd8051a9ca95ef486c96d729 Mon Sep 17 00:00:00 2001 From: soyuka Date: Sat, 7 Nov 2020 12:46:21 +0100 Subject: [PATCH 128/160] Fix phpstan --- phpstan.neon.dist | 1 + src/Bridge/Doctrine/MongoDbOdm/CollectionDataProvider.php | 2 +- .../Doctrine/MongoDbOdm/Extension/PaginationExtension.php | 2 +- src/Bridge/Doctrine/MongoDbOdm/ItemDataProvider.php | 2 +- .../Doctrine/MongoDbOdm/SubresourceDataProvider.php | 2 +- tests/Behat/CoverageContext.php | 4 ++-- tests/Bridge/Doctrine/MongoDbOdm/ItemDataProviderTest.php | 2 +- .../Doctrine/MongoDbOdm/SubresourceDataProviderTest.php | 8 ++++---- 8 files changed, 12 insertions(+), 11 deletions(-) diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 052dfb9adac..8ec0df59968 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -30,6 +30,7 @@ parameters: - src/Bridge/Symfony/Bundle/Test/BrowserKitAssertionsTrait.php - tests/Bridge/Symfony/Bundle/Test/WebTestCaseTest.php - tests/ProphecyTrait.php + - tests/Behat/CoverageContext.php earlyTerminatingMethodCalls: PHPUnit\Framework\Constraint\Constraint: - fail diff --git a/src/Bridge/Doctrine/MongoDbOdm/CollectionDataProvider.php b/src/Bridge/Doctrine/MongoDbOdm/CollectionDataProvider.php index b8820439d8a..2c0381e444f 100644 --- a/src/Bridge/Doctrine/MongoDbOdm/CollectionDataProvider.php +++ b/src/Bridge/Doctrine/MongoDbOdm/CollectionDataProvider.php @@ -19,9 +19,9 @@ use ApiPlatform\Core\DataProvider\RestrictedDataProviderInterface; use ApiPlatform\Core\Exception\RuntimeException; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; -use Doctrine\Common\Persistence\ManagerRegistry; use Doctrine\ODM\MongoDB\DocumentManager; use Doctrine\ODM\MongoDB\Repository\DocumentRepository; +use Doctrine\Persistence\ManagerRegistry; /** * Collection data provider for the Doctrine MongoDB ODM. diff --git a/src/Bridge/Doctrine/MongoDbOdm/Extension/PaginationExtension.php b/src/Bridge/Doctrine/MongoDbOdm/Extension/PaginationExtension.php index 80399ffbc20..cb6dae92d66 100644 --- a/src/Bridge/Doctrine/MongoDbOdm/Extension/PaginationExtension.php +++ b/src/Bridge/Doctrine/MongoDbOdm/Extension/PaginationExtension.php @@ -17,10 +17,10 @@ use ApiPlatform\Core\DataProvider\Pagination; use ApiPlatform\Core\Exception\RuntimeException; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; -use Doctrine\Common\Persistence\ManagerRegistry; use Doctrine\ODM\MongoDB\Aggregation\Builder; use Doctrine\ODM\MongoDB\DocumentManager; use Doctrine\ODM\MongoDB\Repository\DocumentRepository; +use Doctrine\Persistence\ManagerRegistry; /** * Applies pagination on the Doctrine aggregation for resource collection when enabled. diff --git a/src/Bridge/Doctrine/MongoDbOdm/ItemDataProvider.php b/src/Bridge/Doctrine/MongoDbOdm/ItemDataProvider.php index 12db2dda163..adf95c289f6 100644 --- a/src/Bridge/Doctrine/MongoDbOdm/ItemDataProvider.php +++ b/src/Bridge/Doctrine/MongoDbOdm/ItemDataProvider.php @@ -23,9 +23,9 @@ use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; -use Doctrine\Common\Persistence\ManagerRegistry; use Doctrine\ODM\MongoDB\DocumentManager; use Doctrine\ODM\MongoDB\Repository\DocumentRepository; +use Doctrine\Persistence\ManagerRegistry; /** * Item data provider for the Doctrine MongoDB ODM. diff --git a/src/Bridge/Doctrine/MongoDbOdm/SubresourceDataProvider.php b/src/Bridge/Doctrine/MongoDbOdm/SubresourceDataProvider.php index beceaf0a22d..d1d2ad3533b 100644 --- a/src/Bridge/Doctrine/MongoDbOdm/SubresourceDataProvider.php +++ b/src/Bridge/Doctrine/MongoDbOdm/SubresourceDataProvider.php @@ -25,11 +25,11 @@ use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; -use Doctrine\Common\Persistence\ManagerRegistry; use Doctrine\ODM\MongoDB\Aggregation\Builder; use Doctrine\ODM\MongoDB\DocumentManager; use Doctrine\ODM\MongoDB\Mapping\ClassMetadata; use Doctrine\ODM\MongoDB\Repository\DocumentRepository; +use Doctrine\Persistence\ManagerRegistry; /** * Subresource data provider for the Doctrine MongoDB ODM. diff --git a/tests/Behat/CoverageContext.php b/tests/Behat/CoverageContext.php index 611c661ae3c..1b41bd426e8 100644 --- a/tests/Behat/CoverageContext.php +++ b/tests/Behat/CoverageContext.php @@ -47,8 +47,8 @@ public static function setup() return; } - $filter->addDirectoryToWhitelist(__DIR__.'/../../src'); // @phpstan-ignore-line - self::$coverage = new CodeCoverage(null, $filter); // @phpstan-ignore-line + $filter->addDirectoryToWhitelist(__DIR__.'/../../src'); + self::$coverage = new CodeCoverage(null, $filter); } /** diff --git a/tests/Bridge/Doctrine/MongoDbOdm/ItemDataProviderTest.php b/tests/Bridge/Doctrine/MongoDbOdm/ItemDataProviderTest.php index ef82b850814..b38d3cd1073 100644 --- a/tests/Bridge/Doctrine/MongoDbOdm/ItemDataProviderTest.php +++ b/tests/Bridge/Doctrine/MongoDbOdm/ItemDataProviderTest.php @@ -96,7 +96,7 @@ public function testGetItemWithExecuteOptions() { $context = ['foo' => 'bar', 'fetch_data' => true, IdentifierConverterInterface::HAS_IDENTIFIER_CONVERTER => true]; - $matchProphecy = $this->prophesize(Match::class); + $matchProphecy = $this->prophesize(AggregationMatch::class); $matchProphecy->field('id')->willReturn($matchProphecy)->shouldBeCalled(); $matchProphecy->equals(1)->shouldBeCalled(); diff --git a/tests/Bridge/Doctrine/MongoDbOdm/SubresourceDataProviderTest.php b/tests/Bridge/Doctrine/MongoDbOdm/SubresourceDataProviderTest.php index c36ed0de095..0130565ffcc 100644 --- a/tests/Bridge/Doctrine/MongoDbOdm/SubresourceDataProviderTest.php +++ b/tests/Bridge/Doctrine/MongoDbOdm/SubresourceDataProviderTest.php @@ -272,7 +272,7 @@ public function testGetSubSubresourceItemWithExecuteOptions() $dummyLookup->alias('relatedDummies')->shouldBeCalled(); $dummyAggregationBuilder->lookup('relatedDummies')->shouldBeCalled()->willReturn($dummyLookup->reveal()); - $dummyMatch = $this->prophesize(Match::class); + $dummyMatch = $this->prophesize(AggregationMatch::class); $dummyMatch->equals(1)->shouldBeCalled(); $dummyMatch->field('id')->shouldBeCalled()->willReturn($dummyMatch); $dummyAggregationBuilder->match()->shouldBeCalled()->willReturn($dummyMatch->reveal()); @@ -296,10 +296,10 @@ public function testGetSubSubresourceItemWithExecuteOptions() $rLookup->alias('thirdLevel')->shouldBeCalled(); $rAggregationBuilder->lookup('thirdLevel')->shouldBeCalled()->willReturn($rLookup->reveal()); - $rMatch = $this->prophesize(Match::class); + $rMatch = $this->prophesize(AggregationMatch::class); $rMatch->equals(1)->shouldBeCalled(); $rMatch->field('id')->shouldBeCalled()->willReturn($rMatch); - $previousRMatch = $this->prophesize(Match::class); + $previousRMatch = $this->prophesize(AggregationMatch::class); $previousRMatch->in([2])->shouldBeCalled(); $previousRMatch->field('_id')->shouldBeCalled()->willReturn($previousRMatch); $rAggregationBuilder->match()->shouldBeCalled()->willReturn($rMatch->reveal(), $previousRMatch->reveal()); @@ -321,7 +321,7 @@ public function testGetSubSubresourceItemWithExecuteOptions() // Origin manager (ThirdLevel) $aggregationBuilder = $this->prophesize(Builder::class); - $match = $this->prophesize(Match::class); + $match = $this->prophesize(AggregationMatch::class); $match->in([3])->shouldBeCalled(); $match->field('_id')->shouldBeCalled()->willReturn($match); $aggregationBuilder->match()->shouldBeCalled()->willReturn($match); From 003608e7a5c7841e69c76b52483899c0c64e07be Mon Sep 17 00:00:00 2001 From: soyuka Date: Sat, 7 Nov 2020 12:57:19 +0100 Subject: [PATCH 129/160] Fix mercure context --- tests/Behat/MercureContext.php | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/tests/Behat/MercureContext.php b/tests/Behat/MercureContext.php index ace847fb7bd..3b05f65e39d 100644 --- a/tests/Behat/MercureContext.php +++ b/tests/Behat/MercureContext.php @@ -12,9 +12,8 @@ declare(strict_types=1); use ApiPlatform\Core\Tests\Fixtures\DummyMercurePublisher; +use Behat\Behat\Context\Context; use Behat\Gherkin\Node\PyStringNode; -use Behat\Symfony2Extension\Context\KernelAwareContext; -use Symfony\Component\HttpKernel\KernelInterface; use Symfony\Component\Mercure\Update; /** @@ -22,13 +21,13 @@ * * @author Alan Poulain */ -final class MercureContext implements KernelAwareContext +final class MercureContext implements Context { - private $kernel; + private $publisher; - public function setKernel(KernelInterface $kernel): void + public function __construct(DummyMercurePublisher $publisher) { - $this->kernel = $kernel; + $this->publisher = $publisher; } /** @@ -38,11 +37,9 @@ public function theFollowingMercureUpdateShouldHaveBeenSent(string $topics, PySt { $topics = explode(',', $topics); $update = json_decode($update->getRaw(), true); - /** @var DummyMercurePublisher $publisher */ - $publisher = $this->kernel->getContainer()->get('mercure.hub.default.publisher'); /** @var Update $sentUpdate */ - foreach ($publisher->getUpdates() as $sentUpdate) { + foreach ($this->publisher->getUpdates() as $sentUpdate) { $toMatchTopics = count($topics); foreach ($sentUpdate->getTopics() as $sentTopic) { foreach ($topics as $topic) { From 9f3f840bc4c35a448fd70e8327b82f60a1173193 Mon Sep 17 00:00:00 2001 From: soyuka Date: Sat, 7 Nov 2020 13:36:02 +0100 Subject: [PATCH 130/160] Groups should be an array --- tests/Fixtures/TestBundle/Document/ContainNonResource.php | 2 +- tests/Fixtures/TestBundle/Document/DummyCar.php | 2 +- tests/Fixtures/TestBundle/Entity/ContainNonResource.php | 2 +- tests/Fixtures/TestBundle/Entity/DummyCar.php | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/Fixtures/TestBundle/Document/ContainNonResource.php b/tests/Fixtures/TestBundle/Document/ContainNonResource.php index 960c2b7449c..f3823cb7934 100644 --- a/tests/Fixtures/TestBundle/Document/ContainNonResource.php +++ b/tests/Fixtures/TestBundle/Document/ContainNonResource.php @@ -25,7 +25,7 @@ * * @ApiResource( * normalizationContext={ - * "groups"="contain_non_resource", + * "groups"={"contain_non_resource"}, * }, * ) * diff --git a/tests/Fixtures/TestBundle/Document/DummyCar.php b/tests/Fixtures/TestBundle/Document/DummyCar.php index ea64c6babe7..34af7e629e9 100644 --- a/tests/Fixtures/TestBundle/Document/DummyCar.php +++ b/tests/Fixtures/TestBundle/Document/DummyCar.php @@ -29,7 +29,7 @@ * itemOperations={"get"={"swagger_context"={"tags"={}}, "openapi_context"={"tags"={}}}, "put", "delete"}, * attributes={ * "sunset"="2050-01-01", - * "normalization_context"={"groups"="colors"} + * "normalization_context"={"groups"={"colors"}} * } * ) * @ODM\Document diff --git a/tests/Fixtures/TestBundle/Entity/ContainNonResource.php b/tests/Fixtures/TestBundle/Entity/ContainNonResource.php index 484a4e83474..619e2ed8ccc 100644 --- a/tests/Fixtures/TestBundle/Entity/ContainNonResource.php +++ b/tests/Fixtures/TestBundle/Entity/ContainNonResource.php @@ -25,7 +25,7 @@ * * @ApiResource( * normalizationContext={ - * "groups"="contain_non_resource", + * "groups"={"contain_non_resource"}, * }, * ) * diff --git a/tests/Fixtures/TestBundle/Entity/DummyCar.php b/tests/Fixtures/TestBundle/Entity/DummyCar.php index ad0660f65a9..ad9241e0174 100644 --- a/tests/Fixtures/TestBundle/Entity/DummyCar.php +++ b/tests/Fixtures/TestBundle/Entity/DummyCar.php @@ -29,7 +29,7 @@ * itemOperations={"get"={"swagger_context"={"tags"={}}, "openapi_context"={"tags"={}}}, "put", "delete"}, * attributes={ * "sunset"="2050-01-01", - * "normalization_context"={"groups"="colors"} + * "normalization_context"={"groups"={"colors"}} * } * ) * @ORM\Entity From d26805c81a72f484ae0e6119518e6cf086fa40d2 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Sat, 7 Nov 2020 14:25:31 +0100 Subject: [PATCH 131/160] Fix sf5 deprecation notices (#3) * Fix sf5 deprecation notices * Use final Co-authored-by: Thomas Billard --- .../Symfony/Bundle/ApiPlatformBundle.php | 2 + .../DeprecateMercurePublisherPass.php | 41 ++++++++++++++ .../DependencyInjection/Configuration.php | 22 ++++---- .../config/doctrine_orm_mercure_publisher.xml | 4 -- .../Symfony/Bundle/ApiPlatformBundleTest.php | 2 + .../DeprecateMercurePublisherPassTest.php | 53 +++++++++++++++++++ 6 files changed, 109 insertions(+), 15 deletions(-) create mode 100644 src/Bridge/Symfony/Bundle/DependencyInjection/Compiler/DeprecateMercurePublisherPass.php create mode 100644 tests/Bridge/Symfony/Bundle/DependencyInjection/Compiler/DeprecateMercurePublisherPassTest.php diff --git a/src/Bridge/Symfony/Bundle/ApiPlatformBundle.php b/src/Bridge/Symfony/Bundle/ApiPlatformBundle.php index a80a8a40a48..3411ee33937 100644 --- a/src/Bridge/Symfony/Bundle/ApiPlatformBundle.php +++ b/src/Bridge/Symfony/Bundle/ApiPlatformBundle.php @@ -15,6 +15,7 @@ use ApiPlatform\Core\Bridge\Symfony\Bundle\DependencyInjection\Compiler\AnnotationFilterPass; use ApiPlatform\Core\Bridge\Symfony\Bundle\DependencyInjection\Compiler\DataProviderPass; +use ApiPlatform\Core\Bridge\Symfony\Bundle\DependencyInjection\Compiler\DeprecateMercurePublisherPass; use ApiPlatform\Core\Bridge\Symfony\Bundle\DependencyInjection\Compiler\ElasticsearchClientPass; use ApiPlatform\Core\Bridge\Symfony\Bundle\DependencyInjection\Compiler\FilterPass; use ApiPlatform\Core\Bridge\Symfony\Bundle\DependencyInjection\Compiler\GraphQlMutationResolverPass; @@ -48,6 +49,7 @@ public function build(ContainerBuilder $container) $container->addCompilerPass(new GraphQlTypePass()); $container->addCompilerPass(new GraphQlQueryResolverPass()); $container->addCompilerPass(new GraphQlMutationResolverPass()); + $container->addCompilerPass(new DeprecateMercurePublisherPass()); $container->addCompilerPass(new MetadataAwareNameConverterPass()); } } diff --git a/src/Bridge/Symfony/Bundle/DependencyInjection/Compiler/DeprecateMercurePublisherPass.php b/src/Bridge/Symfony/Bundle/DependencyInjection/Compiler/DeprecateMercurePublisherPass.php new file mode 100644 index 00000000000..a7eb16c5ee5 --- /dev/null +++ b/src/Bridge/Symfony/Bundle/DependencyInjection/Compiler/DeprecateMercurePublisherPass.php @@ -0,0 +1,41 @@ + + * + * 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\Bridge\Symfony\Bundle\DependencyInjection\Compiler; + +use Symfony\Component\Config\Definition\BaseNode; +use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; + +/** + * Handles Mercure Publisher depreciation. + * + * @internal calls `setDeprecated` method with valid arguments + * depending which version of symfony/dependency-injection is used + */ +final class DeprecateMercurePublisherPass implements CompilerPassInterface +{ + public function process(ContainerBuilder $container) + { + $container + ->setAlias('api_platform.doctrine.listener.mercure.publish', 'api_platform.doctrine.orm.listener.mercure.publish') + ->setDeprecated(...$this->buildDeprecationArgs('2.6', 'Using "%alias_id%" service is deprecated since API Platform 2.6. Use "api_platform.doctrine.orm.listener.mercure.publish" instead.')); + } + + private function buildDeprecationArgs(string $version, string $message): array + { + return method_exists(BaseNode::class, 'getDeprecation') + ? ['api-platform/core', $version, $message] + : [$message]; + } +} diff --git a/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php b/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php index f55b0b2ee33..a21798b3241 100644 --- a/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php +++ b/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php @@ -136,37 +136,37 @@ public function getConfigTreeBuilder() ->addDefaultsIfNotSet() ->children() ->booleanNode('enabled') - ->setDeprecated('The use of the `collection.pagination.enabled` has been deprecated in 2.6 and will be removed in 3.0. Use `defaults.pagination_enabled` instead.') + ->setDeprecated(...$this->buildDeprecationArgs('2.6', 'The use of the `collection.pagination.enabled` has been deprecated in 2.6 and will be removed in 3.0. Use `defaults.pagination_enabled` instead.')) ->defaultTrue() ->info('To enable or disable pagination for all resource collections by default.') ->end() ->booleanNode('partial') - ->setDeprecated('The use of the `collection.pagination.partial` has been deprecated in 2.6 and will be removed in 3.0. Use `defaults.pagination_partial` instead.') + ->setDeprecated(...$this->buildDeprecationArgs('2.6', 'The use of the `collection.pagination.partial` has been deprecated in 2.6 and will be removed in 3.0. Use `defaults.pagination_partial` instead.')) ->defaultFalse() ->info('To enable or disable partial pagination for all resource collections by default when pagination is enabled.') ->end() ->booleanNode('client_enabled') - ->setDeprecated('The use of the `collection.pagination.client_enabled` has been deprecated in 2.6 and will be removed in 3.0. Use `defaults.pagination_client_enabled` instead.') + ->setDeprecated(...$this->buildDeprecationArgs('2.6', 'The use of the `collection.pagination.client_enabled` has been deprecated in 2.6 and will be removed in 3.0. Use `defaults.pagination_client_enabled` instead.')) ->defaultFalse() ->info('To allow the client to enable or disable the pagination.') ->end() ->booleanNode('client_items_per_page') - ->setDeprecated('The use of the `collection.pagination.client_items_per_page` has been deprecated in 2.6 and will be removed in 3.0. Use `defaults.pagination_client_items_per_page` instead.') + ->setDeprecated(...$this->buildDeprecationArgs('2.6', 'The use of the `collection.pagination.client_items_per_page` has been deprecated in 2.6 and will be removed in 3.0. Use `defaults.pagination_client_items_per_page` instead.')) ->defaultFalse() ->info('To allow the client to set the number of items per page.') ->end() ->booleanNode('client_partial') - ->setDeprecated('The use of the `collection.pagination.client_partial` has been deprecated in 2.6 and will be removed in 3.0. Use `defaults.pagination_client_partial` instead.') + ->setDeprecated(...$this->buildDeprecationArgs('2.6', 'The use of the `collection.pagination.client_partial` has been deprecated in 2.6 and will be removed in 3.0. Use `defaults.pagination_client_partial` instead.')) ->defaultFalse() ->info('To allow the client to enable or disable partial pagination.') ->end() ->integerNode('items_per_page') - ->setDeprecated('The use of the `collection.pagination.items_per_page` has been deprecated in 2.6 and will be removed in 3.0. Use `defaults.pagination_items_per_page` instead.') + ->setDeprecated(...$this->buildDeprecationArgs('2.6', 'The use of the `collection.pagination.items_per_page` has been deprecated in 2.6 and will be removed in 3.0. Use `defaults.pagination_items_per_page` instead.')) ->defaultValue(30) ->info('The default number of items per page.') ->end() ->integerNode('maximum_items_per_page') - ->setDeprecated('The use of the `collection.pagination.maximum_items_per_page` has been deprecated in 2.6 and will be removed in 3.0. Use `defaults.pagination_maximum_items_per_page` instead.') + ->setDeprecated(...$this->buildDeprecationArgs('2.6', 'The use of the `collection.pagination.maximum_items_per_page` has been deprecated in 2.6 and will be removed in 3.0. Use `defaults.pagination_maximum_items_per_page` instead.')) ->defaultNull() ->info('The maximum number of items per page.') ->end() @@ -350,22 +350,22 @@ private function addHttpCacheSection(ArrayNodeDefinition $rootNode): void ->addDefaultsIfNotSet() ->children() ->booleanNode('etag') - ->setDeprecated('The use of the `http_cache.etag` has been deprecated in 2.6 and will be removed in 3.0. Use `defaults.cache_headers.etag` instead.') + ->setDeprecated(...$this->buildDeprecationArgs('2.6', 'The use of the `http_cache.etag` has been deprecated in 2.6 and will be removed in 3.0. Use `defaults.cache_headers.etag` instead.')) ->defaultTrue() ->info('Automatically generate etags for API responses.') ->end() ->integerNode('max_age') - ->setDeprecated('The use of the `http_cache.max_age` has been deprecated in 2.6 and will be removed in 3.0. Use `defaults.cache_headers.max_age` instead.') + ->setDeprecated(...$this->buildDeprecationArgs('2.6', 'The use of the `http_cache.max_age` has been deprecated in 2.6 and will be removed in 3.0. Use `defaults.cache_headers.max_age` instead.')) ->defaultNull() ->info('Default value for the response max age.') ->end() ->integerNode('shared_max_age') - ->setDeprecated('The use of the `http_cache.shared_max_age` has been deprecated in 2.6 and will be removed in 3.0. Use `defaults.cache_headers.shared_max_age` instead.') + ->setDeprecated(...$this->buildDeprecationArgs('2.6', 'The use of the `http_cache.shared_max_age` has been deprecated in 2.6 and will be removed in 3.0. Use `defaults.cache_headers.shared_max_age` instead.')) ->defaultNull() ->info('Default value for the response shared (proxy) max age.') ->end() ->arrayNode('vary') - ->setDeprecated('The use of the `http_cache.vary` has been deprecated in 2.6 and will be removed in 3.0. Use `defaults.cache_headers.vary` instead.') + ->setDeprecated(...$this->buildDeprecationArgs('2.6', 'The use of the `http_cache.vary` has been deprecated in 2.6 and will be removed in 3.0. Use `defaults.cache_headers.vary` instead.')) ->defaultValue(['Accept']) ->prototype('scalar')->end() ->info('Default values of the "Vary" HTTP header.') diff --git a/src/Bridge/Symfony/Bundle/Resources/config/doctrine_orm_mercure_publisher.xml b/src/Bridge/Symfony/Bundle/Resources/config/doctrine_orm_mercure_publisher.xml index 6868de419b7..fbd3d138618 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/doctrine_orm_mercure_publisher.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/doctrine_orm_mercure_publisher.xml @@ -23,10 +23,6 @@
- - Using "%alias_id%" service is deprecated since API Platform 2.6. Use "api_platform.doctrine.orm.listener.mercure.publish" instead. - - diff --git a/tests/Bridge/Symfony/Bundle/ApiPlatformBundleTest.php b/tests/Bridge/Symfony/Bundle/ApiPlatformBundleTest.php index 6015d0d0b56..ce92910072d 100644 --- a/tests/Bridge/Symfony/Bundle/ApiPlatformBundleTest.php +++ b/tests/Bridge/Symfony/Bundle/ApiPlatformBundleTest.php @@ -16,6 +16,7 @@ use ApiPlatform\Core\Bridge\Symfony\Bundle\ApiPlatformBundle; use ApiPlatform\Core\Bridge\Symfony\Bundle\DependencyInjection\Compiler\AnnotationFilterPass; use ApiPlatform\Core\Bridge\Symfony\Bundle\DependencyInjection\Compiler\DataProviderPass; +use ApiPlatform\Core\Bridge\Symfony\Bundle\DependencyInjection\Compiler\DeprecateMercurePublisherPass; use ApiPlatform\Core\Bridge\Symfony\Bundle\DependencyInjection\Compiler\ElasticsearchClientPass; use ApiPlatform\Core\Bridge\Symfony\Bundle\DependencyInjection\Compiler\FilterPass; use ApiPlatform\Core\Bridge\Symfony\Bundle\DependencyInjection\Compiler\GraphQlMutationResolverPass; @@ -45,6 +46,7 @@ public function testBuild() $containerProphecy->addCompilerPass(Argument::type(GraphQlTypePass::class))->shouldBeCalled(); $containerProphecy->addCompilerPass(Argument::type(GraphQlQueryResolverPass::class))->shouldBeCalled(); $containerProphecy->addCompilerPass(Argument::type(GraphQlMutationResolverPass::class))->shouldBeCalled(); + $containerProphecy->addCompilerPass(Argument::type(DeprecateMercurePublisherPass::class))->shouldBeCalled(); $containerProphecy->addCompilerPass(Argument::type(MetadataAwareNameConverterPass::class))->shouldBeCalled(); $bundle = new ApiPlatformBundle(); diff --git a/tests/Bridge/Symfony/Bundle/DependencyInjection/Compiler/DeprecateMercurePublisherPassTest.php b/tests/Bridge/Symfony/Bundle/DependencyInjection/Compiler/DeprecateMercurePublisherPassTest.php new file mode 100644 index 00000000000..e83c0f6bb1d --- /dev/null +++ b/tests/Bridge/Symfony/Bundle/DependencyInjection/Compiler/DeprecateMercurePublisherPassTest.php @@ -0,0 +1,53 @@ + + * + * 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\Tests\Bridge\Symfony\Bundle\DependencyInjection\Compiler; + +use ApiPlatform\Core\Bridge\Symfony\Bundle\DependencyInjection\Compiler\DeprecateMercurePublisherPass; +use ApiPlatform\Core\Tests\ProphecyTrait; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Config\Definition\BaseNode; +use Symfony\Component\DependencyInjection\Alias; +use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; + +final class DeprecateMercurePublisherPassTest extends TestCase +{ + use ProphecyTrait; + + public function testProcess() + { + $deprecateMercurePublisherPass = new DeprecateMercurePublisherPass(); + + $this->assertInstanceOf(CompilerPassInterface::class, $deprecateMercurePublisherPass); + + $containerBuilderProphecy = $this->prophesize(ContainerBuilder::class); + $aliasProphecy = $this->prophesize(Alias::class); + + $containerBuilderProphecy + ->setAlias('api_platform.doctrine.listener.mercure.publish', 'api_platform.doctrine.orm.listener.mercure.publish') + ->willReturn($aliasProphecy->reveal()) + ->shouldBeCalled(); + + $setDeprecatedArgs = method_exists(BaseNode::class, 'getDeprecation') + ? ['api-platform/core', '2.6', 'Using "%alias_id%" service is deprecated since API Platform 2.6. Use "api_platform.doctrine.orm.listener.mercure.publish" instead.'] + : ['Using "%alias_id%" service is deprecated since API Platform 2.6. Use "api_platform.doctrine.orm.listener.mercure.publish" instead.']; + + $aliasProphecy + ->setDeprecated(...$setDeprecatedArgs) + ->willReturn($aliasProphecy->reveal()) + ->shouldBeCalled(); + + $deprecateMercurePublisherPass->process($containerBuilderProphecy->reveal()); + } +} From 33cfa2c9d917f99d2e5dfe90b1c838630e39d37c Mon Sep 17 00:00:00 2001 From: soyuka Date: Sat, 7 Nov 2020 14:44:18 +0100 Subject: [PATCH 132/160] fix tests --- .github/workflows/ci.yml | 2 +- .../Bundle/DependencyInjection/ApiPlatformExtensionTest.php | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 15f2e524855..7b385abf82f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -596,7 +596,7 @@ jobs: - '7.4' fail-fast: false env: - SYMFONY_DEPRECATIONS_HELPER: max[total]=7 + SYMFONY_DEPRECATIONS_HELPER: max[total]=8 steps: - name: Checkout uses: actions/checkout@v2 diff --git a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php index d09c350f8ab..b4a580fb31d 100644 --- a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php +++ b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php @@ -1357,7 +1357,6 @@ private function getBaseContainerBuilderProphecy(array $doctrineIntegrationsToLo BooleanFilter::class => 'api_platform.doctrine.orm.boolean_filter', NumericFilter::class => 'api_platform.doctrine.orm.numeric_filter', ExistsFilter::class => 'api_platform.doctrine.orm.exists_filter', - 'api_platform.doctrine.listener.mercure.publish' => 'api_platform.doctrine.orm.listener.mercure.publish', GraphQlSerializerContextBuilderInterface::class => 'api_platform.graphql.serializer.context_builder', ]; From ea698f865106920f0641df3a59dd0de4f8438796 Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 15 Oct 2020 14:07:53 +0200 Subject: [PATCH 133/160] OpenApi refactor on path docs.json?spec_version=3 --- src/Bridge/Symfony/Bundle/Resources/config/api.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Bridge/Symfony/Bundle/Resources/config/api.xml b/src/Bridge/Symfony/Bundle/Resources/config/api.xml index ad628a51206..eb8ec8d60a3 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/api.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/api.xml @@ -240,6 +240,7 @@ %api_platform.version% null %api_platform.swagger.versions% + From 49ce53460b503cd4f77689314766322a57ae5696 Mon Sep 17 00:00:00 2001 From: soyuka Date: Wed, 14 Oct 2020 12:19:03 +0200 Subject: [PATCH 134/160] Fix openapi schema definitions for subresources --- features/openapi/docs.feature | 110 +++++++++++++++++-- src/JsonSchema/SchemaFactory.php | 7 +- src/OpenApi/Factory/OpenApiFactory.php | 15 +-- tests/Behat/OpenApiContext.php | 2 +- tests/OpenApi/Factory/OpenApiFactoryTest.php | 4 +- 5 files changed, 114 insertions(+), 24 deletions(-) diff --git a/features/openapi/docs.feature b/features/openapi/docs.feature index 814e834421f..3952c966d30 100644 --- a/features/openapi/docs.feature +++ b/features/openapi/docs.feature @@ -10,7 +10,7 @@ Feature: Documentation support And the response should be in JSON And the header "Content-Type" should be equal to "application/json; charset=utf-8" # Context - And the JSON node "openapi" should be equal to "3.0.2" + And the JSON node "openapi" should be equal to "3.0.3" # Root properties And the JSON node "info.title" should be equal to "My Dummy API" And the JSON node "info.description" should contain "This is a test API." @@ -56,47 +56,135 @@ Feature: Documentation support And "id" property exists for the OpenAPI class "Dummy" And "name" property is required for OpenAPI class "Dummy" # Filters + And the JSON node "paths./dummies.get.parameters[3].name" should be equal to "dummyBoolean" + And the JSON node "paths./dummies.get.parameters[3].in" should be equal to "query" + And the JSON node "paths./dummies.get.parameters[3].required" should be false + And the JSON node "paths./dummies.get.parameters[3].schema.type" should be equal to "boolean" + + # Subcollection - check filter on subResource + And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[0].name" should be equal to "id" + And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[0].in" should be equal to "path" + And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[0].required" should be true + And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[0].schema.type" should be equal to "string" + + And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[1].name" should be equal to "page" + And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[1].in" should be equal to "query" + And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[1].required" should be false + And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[1].schema.type" should be equal to "integer" + + And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[2].name" should be equal to "itemsPerPage" + And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[2].in" should be equal to "query" + And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[2].required" should be false + And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[2].schema.type" should be equal to "integer" + + And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[3].name" should be equal to "pagination" + And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[3].in" should be equal to "query" + And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[3].required" should be false + And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[3].schema.type" should be equal to "boolean" + + And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[4].name" should be equal to "name" + And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[4].in" should be equal to "query" + And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[4].required" should be false + And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[4].schema.type" should be equal to "string" + + And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters" should have 5 elements + + # Subcollection - check schema + And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.responses.200.content.application/ld+json.schema.properties.hydra:member.items.$ref" should be equal to "#/components/schemas/RelatedToDummyFriend.jsonld-fakemanytomany" + + # Deprecations + And the JSON node "paths./dummies.get.deprecated" should be false + And the JSON node "paths./deprecated_resources.get.deprecated" should be true + And the JSON node "paths./deprecated_resources.post.deprecated" should be true + And the JSON node "paths./deprecated_resources/{id}.get.deprecated" should be true + And the JSON node "paths./deprecated_resources/{id}.delete.deprecated" should be true + And the JSON node "paths./deprecated_resources/{id}.put.deprecated" should be true + And the JSON node "paths./deprecated_resources/{id}.patch.deprecated" should be true + + @createSchema + Scenario: Retrieve the Swagger documentation + Given I send a "GET" request to "/docs.json?spec_version=2" + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/json; charset=utf-8" + # Context + And the JSON node "swagger" should be equal to "2.0" + # Root properties + And the JSON node "info.title" should be equal to "My Dummy API" + And the JSON node "info.description" should contain "This is a test API." + And the JSON node "info.description" should contain "Made with love" + # Supported classes + And the Swagger class "AbstractDummy" exists + And the Swagger class "CircularReference" exists + And the Swagger class "CircularReference-circular" exists + And the Swagger class "CompositeItem" exists + And the Swagger class "CompositeLabel" exists + And the Swagger class "ConcreteDummy" exists + And the Swagger class "CustomIdentifierDummy" exists + And the Swagger class "CustomNormalizedDummy-input" exists + And the Swagger class "CustomNormalizedDummy-output" exists + And the Swagger class "CustomWritableIdentifierDummy" exists + And the Swagger class "Dummy" exists + And the Swagger class "RelatedDummy" exists + And the Swagger class "DummyTableInheritance" exists + And the Swagger class "DummyTableInheritanceChild" exists + And the Swagger class "OverriddenOperationDummy-overridden_operation_dummy_get" exists + And the Swagger class "OverriddenOperationDummy-overridden_operation_dummy_put" exists + And the Swagger class "OverriddenOperationDummy-overridden_operation_dummy_read" exists + And the Swagger class "OverriddenOperationDummy-overridden_operation_dummy_write" exists + And the Swagger class "RelatedDummy" exists + And the Swagger class "NoCollectionDummy" exists + And the Swagger class "RelatedToDummyFriend" exists + And the Swagger class "RelatedToDummyFriend-fakemanytomany" exists + And the Swagger class "DummyFriend" exists + And the Swagger class "RelationEmbedder-barcelona" exists + And the Swagger class "RelationEmbedder-chicago" exists + And the Swagger class "User-user_user-read" exists + And the Swagger class "User-user_user-write" exists + And the Swagger class "UuidIdentifierDummy" exists + And the Swagger class "ThirdLevel" exists + And the Swagger class "ParentDummy" doesn't exist + And the Swagger class "UnknownDummy" doesn't exist + And the Swagger path "/relation_embedders/{id}/custom" exists + And the Swagger path "/override/swagger" exists + And the Swagger path "/api/custom-call/{id}" exists + And the JSON node "paths./api/custom-call/{id}.get" should exist + And the JSON node "paths./api/custom-call/{id}.put" should exist + # Properties + And "id" property exists for the Swagger class "Dummy" + And "name" property is required for Swagger class "Dummy" + # Filters And the JSON node "paths./dummies.get.parameters[0].name" should be equal to "dummyBoolean" And the JSON node "paths./dummies.get.parameters[0].in" should be equal to "query" And the JSON node "paths./dummies.get.parameters[0].required" should be false - And the JSON node "paths./dummies.get.parameters[0].schema.type" should be equal to "boolean" # Subcollection - check filter on subResource And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[0].name" should be equal to "id" And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[0].in" should be equal to "path" And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[0].required" should be true - And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[0].schema.type" should be equal to "string" And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[1].name" should be equal to "name" And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[1].in" should be equal to "query" And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[1].required" should be false - And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[1].schema.type" should be equal to "string" And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[2].name" should be equal to "description" And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[2].in" should be equal to "query" And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[2].required" should be false - And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[2].schema.type" should be equal to "string" And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[3].name" should be equal to "page" And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[3].in" should be equal to "query" And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[3].required" should be false - And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[3].schema.type" should be equal to "integer" And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[4].name" should be equal to "itemsPerPage" And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[4].in" should be equal to "query" And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[4].required" should be false - And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[4].schema.type" should be equal to "integer" And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[5].name" should be equal to "pagination" And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[5].in" should be equal to "query" And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[5].required" should be false - And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[5].schema.type" should be equal to "boolean" And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters" should have 6 elements - # Subcollection - check schema - And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.responses.200.content.application/ld+json.schema.properties.hydra:member.items.$ref" should be equal to "#/components/schemas/RelatedToDummyFriend.jsonld-fakemanytomany" - # Deprecations And the JSON node "paths./dummies.get.deprecated" should not exist And the JSON node "paths./deprecated_resources.get.deprecated" should be true diff --git a/src/JsonSchema/SchemaFactory.php b/src/JsonSchema/SchemaFactory.php index 95dc09da6df..d2ff5cb31ec 100644 --- a/src/JsonSchema/SchemaFactory.php +++ b/src/JsonSchema/SchemaFactory.php @@ -20,6 +20,7 @@ use ApiPlatform\Core\Metadata\Property\PropertyMetadata; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; +use ApiPlatform\Core\OpenApi\Factory\OpenApiFactory; use ApiPlatform\Core\Swagger\Serializer\DocumentationNormalizer; use ApiPlatform\Core\Util\ResourceClassInfoTrait; use Symfony\Component\PropertyInfo\Type; @@ -218,7 +219,6 @@ private function buildPropertySchema(Schema $schema, string $definitionName, str } $propertySchema = new \ArrayObject($propertySchema + $valueSchema); - $schema->getDefinitions()[$definitionName]['properties'][$normalizedPropertyName] = $propertySchema; } @@ -238,8 +238,9 @@ private function buildDefinitionName(string $className, string $format = 'json', $prefix .= '.'.$format; } - if (isset($serializerContext[DocumentationNormalizer::SWAGGER_DEFINITION_NAME])) { - $name = sprintf('%s-%s', $prefix, $serializerContext[DocumentationNormalizer::SWAGGER_DEFINITION_NAME]); + $definitionName = $serializerContext[OpenApiFactory::OPENAPI_DEFINITION_NAME] ?? $serializerContext[DocumentationNormalizer::SWAGGER_DEFINITION_NAME] ?? null; + if ($definitionName) { + $name = sprintf('%s-%s', $prefix, $definitionName); } else { $groups = (array) ($serializerContext[AbstractNormalizer::GROUPS] ?? []); $name = $groups ? sprintf('%s-%s', $prefix, implode('_', $groups)) : $prefix; diff --git a/src/OpenApi/Factory/OpenApiFactory.php b/src/OpenApi/Factory/OpenApiFactory.php index dcf35ab13c1..8b4fec96b55 100644 --- a/src/OpenApi/Factory/OpenApiFactory.php +++ b/src/OpenApi/Factory/OpenApiFactory.php @@ -41,6 +41,7 @@ final class OpenApiFactory implements OpenApiFactoryInterface use FilterLocatorTrait; public const BASE_URL = 'base_url'; + public const OPENAPI_DEFINITION_NAME = 'openapi_definition_name'; private $resourceNameCollectionFactory; private $resourceMetadataFactory; @@ -116,17 +117,20 @@ private function collectPaths(ResourceMetadata $resourceMetadata, string $resour return [$links, $schemas]; } + $rootResourceClass = $resourceClass; foreach ($operations as $operationName => $operation) { + $resourceClass = $operation['resource_class'] ?? $rootResourceClass; $path = $this->getPath($resourceShortName, $operationName, $operation, $operationType); $method = $resourceMetadata->getTypedOperationAttribute($operationType, $operationName, 'method', 'GET'); list($requestMimeTypes, $responseMimeTypes) = $this->getMimeTypes($resourceClass, $operationName, $operationType, $resourceMetadata); $operationId = $operation['openapi_context']['operationId'] ?? lcfirst($operationName).ucfirst($resourceShortName).ucfirst($operationType); $linkedOperationId = 'get'.ucfirst($resourceShortName).ucfirst(OperationType::ITEM); $pathItem = $paths->getPath($path) ?: new Model\PathItem(); + $forceSchemaCollection = OperationType::SUBRESOURCE === $operationType ? ($operation['collection'] ?? false) : false; $operationOutputSchemas = []; foreach ($responseMimeTypes as $operationFormat) { - $operationOutputSchema = $this->jsonSchemaFactory->buildSchema($resourceClass, $operationFormat, Schema::TYPE_OUTPUT, $operationType, $operationName, new Schema('openapi'), $context); + $operationOutputSchema = $this->jsonSchemaFactory->buildSchema($resourceClass, $operationFormat, Schema::TYPE_OUTPUT, $operationType, $operationName, new Schema('openapi'), null, $forceSchemaCollection); $schemas += $operationOutputSchema->getDefinitions()->getArrayCopy(); $operationOutputSchemas[$operationFormat] = $operationOutputSchema; } @@ -197,7 +201,7 @@ private function collectPaths(ResourceMetadata $resourceMetadata, string $resour if ('PUT' === $method || 'POST' === $method || 'PATCH' === $method) { $operationInputSchemas = []; foreach ($requestMimeTypes as $operationFormat) { - $operationInputSchema = $this->jsonSchemaFactory->buildSchema($resourceClass, $operationFormat, Schema::TYPE_INPUT, $operationType, $operationName, new Schema('openapi'), $context); + $operationInputSchema = $this->jsonSchemaFactory->buildSchema($resourceClass, $operationFormat, Schema::TYPE_INPUT, $operationType, $operationName, new Schema('openapi'), null, $forceSchemaCollection); $schemas += $operationInputSchema->getDefinitions()->getArrayCopy(); $operationInputSchemas[$operationFormat] = $operationInputSchema; } @@ -270,15 +274,12 @@ private function flattenMimeTypes(array $responseFormats): array */ private function getPath(string $resourceShortName, string $operationName, array $operation, string $operationType): string { - if ($operation['path'] ?? null) { - return 0 === strpos($operation['path'], '/') ? $operation['path'] : '/'.$operation['path']; - } - $path = $this->operationPathResolver->resolveOperationPath($resourceShortName, $operation, $operationType, $operationName); + $path = $operation['path'] ?? $this->operationPathResolver->resolveOperationPath($resourceShortName, $operation, $operationType, $operationName); if ('.{_format}' === substr($path, -10)) { $path = substr($path, 0, -10); } - return $path; + return 0 === strpos($path, '/') ? $path : '/'.$path; } private function getPathDescription(string $resourceShortName, string $method, string $operationType): string diff --git a/tests/Behat/OpenApiContext.php b/tests/Behat/OpenApiContext.php index 59d9035b5a4..19d06caa9ad 100644 --- a/tests/Behat/OpenApiContext.php +++ b/tests/Behat/OpenApiContext.php @@ -86,7 +86,7 @@ public function assertTheSwaggerClassNotExist(string $className) /** * @Then the OpenAPI class :class doesn't exist */ - public function assertTheOPenAPIClassNotExist(string $className) + public function assertTheOpenAPIClassNotExist(string $className) { try { $this->getClassInfo($className, 3); diff --git a/tests/OpenApi/Factory/OpenApiFactoryTest.php b/tests/OpenApi/Factory/OpenApiFactoryTest.php index 3fc49466d86..ae1111fe024 100644 --- a/tests/OpenApi/Factory/OpenApiFactoryTest.php +++ b/tests/OpenApi/Factory/OpenApiFactoryTest.php @@ -651,7 +651,7 @@ public function testSubresourceDocumentation() $openApi = $factory(['base_url', '/app_dev.php/']); $paths = $openApi->getPaths(); - $pathItem = $paths->getPath('/questions/{id}/answer.{_format}'); + $pathItem = $paths->getPath('/questions/{id}/answer'); $this->assertEquals($pathItem->getGet(), new Model\Operation( 'api_questions_answer_get_subresourceQuestionSubresource', @@ -660,7 +660,7 @@ public function testSubresourceDocumentation() '200' => new Model\Response( 'Question resource', new \ArrayObject([ - 'application/ld+json' => new Model\MediaType(new \ArrayObject(new \ArrayObject(['$ref' => '#/components/schemas/Question']))), + 'application/ld+json' => new Model\MediaType(new \ArrayObject(new \ArrayObject(['$ref' => '#/components/schemas/Answer']))), ]) ), ], From 3605e6d1a1ca77c49dd57e810e2c2ffd1b6c5f13 Mon Sep 17 00:00:00 2001 From: Ahmed EBEN HASSINE Date: Sun, 8 Nov 2020 13:01:27 +0100 Subject: [PATCH 135/160] [DEFAULT ORDER] #1246: Support default order for a specific custom operation (#3784) * feat: update order extension * test: update order behat tests --- features/main/default_order.feature | 112 ++++++++++++++++++ .../MongoDbOdm/Extension/OrderExtension.php | 6 +- .../Doctrine/Orm/Extension/OrderExtension.php | 6 +- tests/Fixtures/TestBundle/Document/Foo.php | 5 + tests/Fixtures/TestBundle/Entity/Foo.php | 5 + 5 files changed, 132 insertions(+), 2 deletions(-) diff --git a/features/main/default_order.feature b/features/main/default_order.feature index 6b39736eee2..1bc97ab37c1 100644 --- a/features/main/default_order.feature +++ b/features/main/default_order.feature @@ -117,3 +117,115 @@ Feature: Default order } } """ + + Scenario: Override custom order asc + When I send a "GET" request to "/custom_collection_asc_foos?itemsPerPage=10" + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the JSON should be equal to: + """ + { + "@context": "/contexts/Foo", + "@id": "/foos", + "@type": "hydra:Collection", + "hydra:member": [ + { + "@id": "/foos/5", + "@type": "Foo", + "id": 5, + "name": "Balbo", + "bar": "Amet" + }, + { + "@id": "/foos/3", + "@type": "Foo", + "id": 3, + "name": "Ephesian", + "bar": "Dolor" + }, + { + "@id": "/foos/1", + "@type": "Foo", + "id": 1, + "name": "Hawsepipe", + "bar": "Lorem" + }, + { + "@id": "/foos/4", + "@type": "Foo", + "id": 4, + "name": "Separativeness", + "bar": "Sit" + }, + { + "@id": "/foos/2", + "@type": "Foo", + "id": 2, + "name": "Sthenelus", + "bar": "Ipsum" + } + ], + "hydra:totalItems": 5, + "hydra:view": { + "@id": "/custom_collection_asc_foos?itemsPerPage=10", + "@type": "hydra:PartialCollectionView" + } + } + """ + + Scenario: Override custom order desc + When I send a "GET" request to "/custom_collection_desc_foos?itemsPerPage=10" + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the JSON should be equal to: + """ + { + "@context": "/contexts/Foo", + "@id": "/foos", + "@type": "hydra:Collection", + "hydra:member": [ + { + "@id": "/foos/2", + "@type": "Foo", + "id": 2, + "name": "Sthenelus", + "bar": "Ipsum" + }, + { + "@id": "/foos/4", + "@type": "Foo", + "id": 4, + "name": "Separativeness", + "bar": "Sit" + }, + { + "@id": "/foos/1", + "@type": "Foo", + "id": 1, + "name": "Hawsepipe", + "bar": "Lorem" + }, + { + "@id": "/foos/3", + "@type": "Foo", + "id": 3, + "name": "Ephesian", + "bar": "Dolor" + }, + { + "@id": "/foos/5", + "@type": "Foo", + "id": 5, + "name": "Balbo", + "bar": "Amet" + } + ], + "hydra:totalItems": 5, + "hydra:view": { + "@id": "/custom_collection_desc_foos?itemsPerPage=10", + "@type": "hydra:PartialCollectionView" + } + } + """ diff --git a/src/Bridge/Doctrine/MongoDbOdm/Extension/OrderExtension.php b/src/Bridge/Doctrine/MongoDbOdm/Extension/OrderExtension.php index 818037073ff..fdd25ccb346 100644 --- a/src/Bridge/Doctrine/MongoDbOdm/Extension/OrderExtension.php +++ b/src/Bridge/Doctrine/MongoDbOdm/Extension/OrderExtension.php @@ -53,7 +53,11 @@ public function applyToCollection(Builder $aggregationBuilder, string $resourceC $classMetaData = $this->getClassMetadata($resourceClass); $identifiers = $classMetaData->getIdentifier(); if (null !== $this->resourceMetadataFactory) { - $defaultOrder = $this->resourceMetadataFactory->create($resourceClass)->getAttribute('order'); + $defaultOrder = $this->resourceMetadataFactory->create($resourceClass) + ->getCollectionOperationAttribute($operationName, 'order', [], true); + if (empty($defaultOrder)) { + $defaultOrder = $this->resourceMetadataFactory->create($resourceClass)->getAttribute('order'); + } if (\is_array($defaultOrder)) { foreach ($defaultOrder as $field => $order) { if (\is_int($field)) { diff --git a/src/Bridge/Doctrine/Orm/Extension/OrderExtension.php b/src/Bridge/Doctrine/Orm/Extension/OrderExtension.php index 0cc5a94d125..bcebbec9879 100644 --- a/src/Bridge/Doctrine/Orm/Extension/OrderExtension.php +++ b/src/Bridge/Doctrine/Orm/Extension/OrderExtension.php @@ -51,7 +51,11 @@ public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGenerator $classMetaData = $queryBuilder->getEntityManager()->getClassMetadata($resourceClass); $identifiers = $classMetaData->getIdentifier(); if (null !== $this->resourceMetadataFactory) { - $defaultOrder = $this->resourceMetadataFactory->create($resourceClass)->getAttribute('order'); + $defaultOrder = $this->resourceMetadataFactory->create($resourceClass) + ->getCollectionOperationAttribute($operationName, 'order', [], true); + if (empty($defaultOrder)) { + $defaultOrder = $this->resourceMetadataFactory->create($resourceClass)->getAttribute('order'); + } if (\is_array($defaultOrder)) { foreach ($defaultOrder as $field => $order) { if (\is_int($field)) { diff --git a/tests/Fixtures/TestBundle/Document/Foo.php b/tests/Fixtures/TestBundle/Document/Foo.php index d159b9362bb..385cacc8f23 100644 --- a/tests/Fixtures/TestBundle/Document/Foo.php +++ b/tests/Fixtures/TestBundle/Document/Foo.php @@ -30,6 +30,11 @@ * "collection_query"={"pagination_enabled"=false}, * "create", * "delete" + * }, + * collectionOperations={ + * "get", + * "get_desc_custom"={"method"="GET", "path"="custom_collection_desc_foos", "order"={"name"="DESC"}}, + * "get_asc_custom"={"method"="GET", "path"="custom_collection_asc_foos", "order"={ "name"="ASC"}}, * } * ) * @ODM\Document diff --git a/tests/Fixtures/TestBundle/Entity/Foo.php b/tests/Fixtures/TestBundle/Entity/Foo.php index dd970d4f639..6368d0bf8e0 100644 --- a/tests/Fixtures/TestBundle/Entity/Foo.php +++ b/tests/Fixtures/TestBundle/Entity/Foo.php @@ -30,6 +30,11 @@ * "collection_query"={"pagination_enabled"=false}, * "create", * "delete" + * }, + * collectionOperations={ + * "get", + * "get_desc_custom"={"method"="GET", "path"="custom_collection_desc_foos", "order"={"name"="DESC"}}, + * "get_asc_custom"={"method"="GET", "path"="custom_collection_asc_foos", "order"={ "name"="ASC"}}, * } * ) * @ORM\Entity From 3cf90f71a2058756653fe3dcf44781a27005d3bf Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Sun, 8 Nov 2020 19:54:57 +0100 Subject: [PATCH 136/160] Fix tests (#3818) --- src/Bridge/Symfony/Bundle/Action/SwaggerUiAction.php | 3 ++- tests/Bridge/Symfony/Bundle/Action/SwaggerUiActionTest.php | 2 ++ tests/Bridge/Symfony/Bundle/SwaggerUi/SwaggerUiActionTest.php | 2 ++ 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Bridge/Symfony/Bundle/Action/SwaggerUiAction.php b/src/Bridge/Symfony/Bundle/Action/SwaggerUiAction.php index 523369f3740..d518f81c8a8 100644 --- a/src/Bridge/Symfony/Bundle/Action/SwaggerUiAction.php +++ b/src/Bridge/Symfony/Bundle/Action/SwaggerUiAction.php @@ -64,7 +64,8 @@ final class SwaggerUiAction private $assetPackage; /** - * @param int[] $swaggerVersions + * @param int[] $swaggerVersions + * @param mixed|null $assetPackage */ public function __construct(ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, ResourceMetadataFactoryInterface $resourceMetadataFactory, NormalizerInterface $normalizer, TwigEnvironment $twig, UrlGeneratorInterface $urlGenerator, string $title = '', string $description = '', string $version = '', $formats = [], $oauthEnabled = false, $oauthClientId = '', $oauthClientSecret = '', $oauthType = '', $oauthFlow = '', $oauthTokenUrl = '', $oauthAuthorizationUrl = '', $oauthScopes = [], bool $showWebby = true, bool $swaggerUiEnabled = false, bool $reDocEnabled = false, bool $graphqlEnabled = false, bool $graphiQlEnabled = false, bool $graphQlPlaygroundEnabled = false, array $swaggerVersions = [2, 3], OpenApiSwaggerUiAction $swaggerUiAction = null, $assetPackage = null) { diff --git a/tests/Bridge/Symfony/Bundle/Action/SwaggerUiActionTest.php b/tests/Bridge/Symfony/Bundle/Action/SwaggerUiActionTest.php index e9b44d52627..ae1c6d3391d 100644 --- a/tests/Bridge/Symfony/Bundle/Action/SwaggerUiActionTest.php +++ b/tests/Bridge/Symfony/Bundle/Action/SwaggerUiActionTest.php @@ -85,6 +85,7 @@ public function getInvokeParameters() 'graphqlEnabled' => false, 'graphiQlEnabled' => false, 'graphQlPlaygroundEnabled' => false, + 'assetPackage' => null, 'swagger_data' => [ 'url' => '/url', 'spec' => self::SPEC, @@ -118,6 +119,7 @@ public function getInvokeParameters() 'graphqlEnabled' => false, 'graphiQlEnabled' => false, 'graphQlPlaygroundEnabled' => false, + 'assetPackage' => null, 'swagger_data' => [ 'url' => '/url', 'spec' => self::SPEC, diff --git a/tests/Bridge/Symfony/Bundle/SwaggerUi/SwaggerUiActionTest.php b/tests/Bridge/Symfony/Bundle/SwaggerUi/SwaggerUiActionTest.php index dcd88de3dbb..f75315d9712 100644 --- a/tests/Bridge/Symfony/Bundle/SwaggerUi/SwaggerUiActionTest.php +++ b/tests/Bridge/Symfony/Bundle/SwaggerUi/SwaggerUiActionTest.php @@ -85,6 +85,7 @@ public function getInvokeParameters() 'graphqlEnabled' => false, 'graphiQlEnabled' => false, 'graphQlPlaygroundEnabled' => false, + 'assetPackage' => null, 'swagger_data' => [ 'url' => '/url', 'spec' => self::SPEC, @@ -118,6 +119,7 @@ public function getInvokeParameters() 'graphqlEnabled' => false, 'graphiQlEnabled' => false, 'graphQlPlaygroundEnabled' => false, + 'assetPackage' => null, 'swagger_data' => [ 'url' => '/url', 'spec' => self::SPEC, From 5b2cfdcd8feea9b9ff96371df1a7eeccc72432f4 Mon Sep 17 00:00:00 2001 From: Ahmed EBEN HASSINE Date: Sun, 8 Nov 2020 20:02:57 +0100 Subject: [PATCH 137/160] [ValidationException] Allow customization of validation error status code (#3808) * feat: support changing validation status code * fix: apply phpcs fixer * fix: ValidationExceptionListener - default to 422 status * fix: grafql default to 422 status * test: update behat features * test: update phpunit tests --- features/graphql/mutation.feature | 2 +- features/hal/problem.feature | 2 +- features/hydra/error.feature | 2 +- features/jsonapi/errors.feature | 4 ++-- features/main/validation.feature | 4 ++-- .../security/send_security_headers.feature | 2 +- .../Symfony/Bundle/Resources/config/api.xml | 1 + .../Bundle/Resources/config/graphql.xml | 2 ++ .../ValidationExceptionListener.php | 16 +++++++++++++-- .../ValidationExceptionNormalizer.php | 20 ++++++++++++++++++- .../ValidationExceptionListenerTest.php | 2 +- .../ValidationExceptionNormalizerTest.php | 2 +- 12 files changed, 46 insertions(+), 13 deletions(-) diff --git a/features/graphql/mutation.feature b/features/graphql/mutation.feature index 0baa2ce0b10..a624047ce62 100644 --- a/features/graphql/mutation.feature +++ b/features/graphql/mutation.feature @@ -674,7 +674,7 @@ Feature: GraphQL mutation support Then the response status code should be 200 And the response should be in JSON And the header "Content-Type" should be equal to "application/json" - And the JSON node "errors[0].extensions.status" should be equal to "400" + And the JSON node "errors[0].extensions.status" should be equal to "422" And the JSON node "errors[0].message" should be equal to "name: This value should not be blank." And the JSON node "errors[0].extensions.violations" should exist And the JSON node "errors[0].extensions.violations[0].path" should be equal to "name" diff --git a/features/hal/problem.feature b/features/hal/problem.feature index fe92ff4ba3f..878d4b21b2f 100644 --- a/features/hal/problem.feature +++ b/features/hal/problem.feature @@ -10,7 +10,7 @@ Feature: Error handling valid according to RFC 7807 (application/problem+json) """ {} """ - Then the response status code should be 400 + Then the response status code should be 422 And the response should be in JSON And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" And the JSON should be equal to: diff --git a/features/hydra/error.feature b/features/hydra/error.feature index 9ec12d469fa..79a3e9e9a04 100644 --- a/features/hydra/error.feature +++ b/features/hydra/error.feature @@ -9,7 +9,7 @@ Feature: Error handling """ {} """ - Then the response status code should be 400 + Then the response status code should be 422 And the response should be in JSON And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" And the JSON should be equal to: diff --git a/features/jsonapi/errors.feature b/features/jsonapi/errors.feature index 7d37824e052..587ee74a837 100644 --- a/features/jsonapi/errors.feature +++ b/features/jsonapi/errors.feature @@ -18,7 +18,7 @@ Feature: JSON API error handling } } """ - Then the response status code should be 400 + Then the response status code should be 422 And the response should be in JSON And the JSON should be valid according to the JSON API schema And the JSON should be equal to: @@ -49,7 +49,7 @@ Feature: JSON API error handling } } """ - Then the response status code should be 400 + Then the response status code should be 422 And the response should be in JSON And the JSON should be valid according to the JSON API schema And the JSON should be equal to: diff --git a/features/main/validation.feature b/features/main/validation.feature index 960f10718be..95b41b7db8c 100644 --- a/features/main/validation.feature +++ b/features/main/validation.feature @@ -24,7 +24,7 @@ Feature: Using validations groups "code": "My Dummy" } """ - Then the response status code should be 400 + Then the response status code should be 422 And the response should be in JSON And the JSON should be equal to: """ @@ -52,7 +52,7 @@ Feature: Using validations groups "code": "My Dummy" } """ - Then the response status code should be 400 + Then the response status code should be 422 And the response should be in JSON And the JSON should be equal to: """ diff --git a/features/security/send_security_headers.feature b/features/security/send_security_headers.feature index d4b91f77491..b09afc7c316 100644 --- a/features/security/send_security_headers.feature +++ b/features/security/send_security_headers.feature @@ -27,7 +27,7 @@ Feature: Send security header """ {"name": ""} """ - Then the response status code should be 400 + Then the response status code should be 422 And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" And the header "X-Content-Type-Options" should be equal to "nosniff" And the header "X-Frame-Options" should be equal to "deny" diff --git a/src/Bridge/Symfony/Bundle/Resources/config/api.xml b/src/Bridge/Symfony/Bundle/Resources/config/api.xml index eb8ec8d60a3..f5b94eb917d 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/api.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/api.xml @@ -202,6 +202,7 @@ %api_platform.error_formats% + %api_platform.exception_to_status% diff --git a/src/Bridge/Symfony/Bundle/Resources/config/graphql.xml b/src/Bridge/Symfony/Bundle/Resources/config/graphql.xml index 48638b0e99a..d9400932005 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/graphql.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/graphql.xml @@ -239,6 +239,8 @@ + %api_platform.exception_to_status% + diff --git a/src/Bridge/Symfony/Validator/EventListener/ValidationExceptionListener.php b/src/Bridge/Symfony/Validator/EventListener/ValidationExceptionListener.php index 5f72211c5e5..d0da61f1d2d 100644 --- a/src/Bridge/Symfony/Validator/EventListener/ValidationExceptionListener.php +++ b/src/Bridge/Symfony/Validator/EventListener/ValidationExceptionListener.php @@ -28,11 +28,13 @@ final class ValidationExceptionListener { private $serializer; private $errorFormats; + private $exceptionToStatus; - public function __construct(SerializerInterface $serializer, array $errorFormats) + public function __construct(SerializerInterface $serializer, array $errorFormats, array $exceptionToStatus = []) { $this->serializer = $serializer; $this->errorFormats = $errorFormats; + $this->exceptionToStatus = $exceptionToStatus; } /** @@ -44,12 +46,22 @@ public function onKernelException(ExceptionEvent $event): void if (!$exception instanceof ValidationException) { return; } + $exceptionClass = \get_class($exception); + $statusCode = Response::HTTP_UNPROCESSABLE_ENTITY; + + foreach ($this->exceptionToStatus as $class => $status) { + if (is_a($exceptionClass, $class, true)) { + $statusCode = $status; + + break; + } + } $format = ErrorFormatGuesser::guessErrorFormat($event->getRequest(), $this->errorFormats); $event->setResponse(new Response( $this->serializer->serialize($exception->getConstraintViolationList(), $format['key']), - Response::HTTP_BAD_REQUEST, + $statusCode, [ 'Content-Type' => sprintf('%s; charset=utf-8', $format['value'][0]), 'X-Content-Type-Options' => 'nosniff', diff --git a/src/GraphQl/Serializer/Exception/ValidationExceptionNormalizer.php b/src/GraphQl/Serializer/Exception/ValidationExceptionNormalizer.php index d62986ef561..f00473ff966 100644 --- a/src/GraphQl/Serializer/Exception/ValidationExceptionNormalizer.php +++ b/src/GraphQl/Serializer/Exception/ValidationExceptionNormalizer.php @@ -30,6 +30,13 @@ */ final class ValidationExceptionNormalizer implements NormalizerInterface { + private $exceptionToStatus; + + public function __construct(array $exceptionToStatus = []) + { + $this->exceptionToStatus = $exceptionToStatus; + } + /** * {@inheritdoc} */ @@ -39,7 +46,18 @@ public function normalize($object, $format = null, array $context = []): array $validationException = $object->getPrevious(); $error = FormattedError::createFromException($object); $error['message'] = $validationException->getMessage(); - $error['extensions']['status'] = Response::HTTP_BAD_REQUEST; + + $exceptionClass = \get_class($validationException); + $statusCode = Response::HTTP_UNPROCESSABLE_ENTITY; + + foreach ($this->exceptionToStatus as $class => $status) { + if (is_a($exceptionClass, $class, true)) { + $statusCode = $status; + + break; + } + } + $error['extensions']['status'] = $statusCode; $error['extensions']['category'] = 'user'; $error['extensions']['violations'] = []; diff --git a/tests/Bridge/Symfony/Validator/EventListener/ValidationExceptionListenerTest.php b/tests/Bridge/Symfony/Validator/EventListener/ValidationExceptionListenerTest.php index 7151e553f36..b1128b477c0 100644 --- a/tests/Bridge/Symfony/Validator/EventListener/ValidationExceptionListenerTest.php +++ b/tests/Bridge/Symfony/Validator/EventListener/ValidationExceptionListenerTest.php @@ -57,7 +57,7 @@ public function testValidationException() $response = $event->getResponse(); $this->assertInstanceOf(Response::class, $response); $this->assertSame($exceptionJson, $response->getContent()); - $this->assertSame(Response::HTTP_BAD_REQUEST, $response->getStatusCode()); + $this->assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $response->getStatusCode()); $this->assertSame('application/ld+json; charset=utf-8', $response->headers->get('Content-Type')); $this->assertSame('nosniff', $response->headers->get('X-Content-Type-Options')); $this->assertSame('deny', $response->headers->get('X-Frame-Options')); diff --git a/tests/GraphQl/Serializer/Exception/ValidationExceptionNormalizerTest.php b/tests/GraphQl/Serializer/Exception/ValidationExceptionNormalizerTest.php index 1f76bc2a17b..bef54b2fbf7 100644 --- a/tests/GraphQl/Serializer/Exception/ValidationExceptionNormalizerTest.php +++ b/tests/GraphQl/Serializer/Exception/ValidationExceptionNormalizerTest.php @@ -48,7 +48,7 @@ public function testNormalize(): void $normalizedError = $this->validationExceptionNormalizer->normalize($error); $this->assertSame($exceptionMessage, $normalizedError['message']); - $this->assertSame(400, $normalizedError['extensions']['status']); + $this->assertSame(422, $normalizedError['extensions']['status']); $this->assertSame('user', $normalizedError['extensions']['category']); $this->assertArrayHasKey('violations', $normalizedError['extensions']); $this->assertSame([ From 04142a6546e8edbf6f5ad2ec7f3bb608c0734548 Mon Sep 17 00:00:00 2001 From: Benjamin Grandfond Date: Wed, 11 Nov 2020 15:15:41 +0100 Subject: [PATCH 138/160] Allow to search on multiple values on every strategies (#3786) --- features/doctrine/search_filter.feature | 120 ++++++++++++++++ .../Doctrine/Orm/Filter/SearchFilter.php | 128 ++++++++++-------- .../Common/Filter/SearchFilterTestTrait.php | 96 +++++++++++++ .../Doctrine/Orm/Filter/SearchFilterTest.php | 102 +++++++++++--- 4 files changed, 368 insertions(+), 78 deletions(-) diff --git a/features/doctrine/search_filter.feature b/features/doctrine/search_filter.feature index b895df1a12a..0ed91ebf66d 100644 --- a/features/doctrine/search_filter.feature +++ b/features/doctrine/search_filter.feature @@ -272,6 +272,47 @@ Feature: Search filter on collections } """ + Scenario: Search collection by name (partial multiple values) + Given there are 30 dummy objects + When I send a "GET" request to "/dummies?name[]=2&name[]=3" + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And print last JSON response + And the JSON should be valid according to this schema: + """ + { + "type": "object", + "properties": { + "@context": {"pattern": "^/contexts/Dummy$"}, + "@id": {"pattern": "^/dummies$"}, + "@type": {"pattern": "^hydra:Collection$"}, + "hydra:member": { + "type": "array", + "items": { + "type": "object", + "properties": { + "@id": { + "oneOf": [ + {"pattern": "^/dummies/2$"}, + {"pattern": "^/dummies/3$"}, + {"pattern": "^/dummies/12$"} + ] + } + } + } + }, + "hydra:view": { + "type": "object", + "properties": { + "@id": {"pattern": "^/dummies\\?name%5B%5D=2&name%5B%5D=3"}, + "@type": {"pattern": "^hydra:PartialCollectionView$"} + } + } + } + } + """ + Scenario: Search collection by name (partial case insensitive) When I send a "GET" request to "/dummies?dummy=somedummytest1" Then the response status code should be 200 @@ -339,6 +380,45 @@ Feature: Search filter on collections } """ + Scenario: Search collection by alias (start multiple values) + When I send a "GET" request to "/dummies?description[]=Sma&description[]=Not" + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the JSON should be valid according to this schema: + """ + { + "type": "object", + "properties": { + "@context": {"pattern": "^/contexts/Dummy$"}, + "@id": {"pattern": "^/dummies$"}, + "@type": {"pattern": "^hydra:Collection$"}, + "hydra:member": { + "type": "array", + "items": { + "type": "object", + "properties": { + "@id": { + "oneOf": [ + {"pattern": "^/dummies/1$"}, + {"pattern": "^/dummies/2$"}, + {"pattern": "^/dummies/3$"} + ] + } + } + } + }, + "hydra:view": { + "type": "object", + "properties": { + "@id": {"pattern": "^/dummies\\?description%5B%5D=Sma&description%5B%5D=Not"}, + "@type": {"pattern": "^hydra:PartialCollectionView$"} + } + } + } + } + """ + @sqlite Scenario: Search collection by description (word_start) When I send a "GET" request to "/dummies?description=smart" @@ -379,6 +459,46 @@ Feature: Search filter on collections } """ + @sqlite + Scenario: Search collection by description (word_start multiple values) + When I send a "GET" request to "/dummies?description[]=smart&description[]=so" + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the JSON should be valid according to this schema: + """ + { + "type": "object", + "properties": { + "@context": {"pattern": "^/contexts/Dummy$"}, + "@id": {"pattern": "^/dummies$"}, + "@type": {"pattern": "^hydra:Collection$"}, + "hydra:member": { + "type": "array", + "items": { + "type": "object", + "properties": { + "@id": { + "oneOf": [ + {"pattern": "^/dummies/1$"}, + {"pattern": "^/dummies/2$"}, + {"pattern": "^/dummies/3$"} + ] + } + } + } + }, + "hydra:view": { + "type": "object", + "properties": { + "@id": {"pattern": "^/dummies\\?description%5B%5D=smart&description%5B%5D=so"}, + "@type": {"pattern": "^hydra:PartialCollectionView$"} + } + } + } + } + """ + # note on Postgres compared to sqlite the LIKE clause is case sensitive @postgres Scenario: Search collection by description (word_start) diff --git a/src/Bridge/Doctrine/Orm/Filter/SearchFilter.php b/src/Bridge/Doctrine/Orm/Filter/SearchFilter.php index b229135b4a6..82c2a2fa256 100644 --- a/src/Bridge/Doctrine/Orm/Filter/SearchFilter.php +++ b/src/Bridge/Doctrine/Orm/Filter/SearchFilter.php @@ -89,7 +89,6 @@ protected function filterProperty(string $property, $value, QueryBuilder $queryB return; } - $caseSensitive = true; $metadata = $this->getNestedMetadata($resourceClass, $associations); if ($metadata->hasField($field)) { @@ -105,6 +104,7 @@ protected function filterProperty(string $property, $value, QueryBuilder $queryB return; } + $caseSensitive = true; $strategy = $this->properties[$property] ?? self::STRATEGY_EXACT; // prefixing the strategy with i makes it case insensitive @@ -113,26 +113,9 @@ protected function filterProperty(string $property, $value, QueryBuilder $queryB $caseSensitive = false; } - if (1 === \count($values)) { - $this->addWhereByStrategy($strategy, $queryBuilder, $queryNameGenerator, $alias, $field, $values[0], $caseSensitive); + $this->addWhereByStrategy($strategy, $queryBuilder, $queryNameGenerator, $alias, $field, $values, $caseSensitive); - return; - } - - if (self::STRATEGY_EXACT !== $strategy) { - $this->logger->notice('Invalid filter ignored', [ - 'exception' => new InvalidArgumentException(sprintf('"%s" strategy selected for "%s" property, but only "%s" strategy supports multiple values', $strategy, $property, self::STRATEGY_EXACT)), - ]); - - return; - } - - $wrapCase = $this->createWrapCase($caseSensitive); - $valueParameter = $queryNameGenerator->generateParameterName($field); - - $queryBuilder - ->andWhere(sprintf($wrapCase('%s.%s').' IN (:%s)', $alias, $field, $valueParameter)) - ->setParameter($valueParameter, $caseSensitive ? $values : array_map('strtolower', $values)); + return; } // metadata doesn't have the field, nor an association on the field @@ -158,23 +141,21 @@ protected function filterProperty(string $property, $value, QueryBuilder $queryB return; } - $association = $field; - $valueParameter = $queryNameGenerator->generateParameterName($association); - if ($metadata->isCollectionValuedAssociation($association)) { - $associationAlias = QueryBuilderHelper::addJoinOnce($queryBuilder, $queryNameGenerator, $alias, $association); + $associationAlias = $alias; + $associationField = $field; + $valueParameter = $queryNameGenerator->generateParameterName($associationField); + if ($metadata->isCollectionValuedAssociation($associationField)) { + $associationAlias = QueryBuilderHelper::addJoinOnce($queryBuilder, $queryNameGenerator, $alias, $associationField); $associationField = $associationFieldIdentifier; - } else { - $associationAlias = $alias; - $associationField = $field; } if (1 === \count($values)) { $queryBuilder - ->andWhere(sprintf('%s.%s = :%s', $associationAlias, $associationField, $valueParameter)) + ->andWhere($queryBuilder->expr()->eq($associationAlias.'.'.$associationField, ':'.$valueParameter)) ->setParameter($valueParameter, $values[0]); } else { $queryBuilder - ->andWhere(sprintf('%s.%s IN (:%s)', $associationAlias, $associationField, $valueParameter)) + ->andWhere($queryBuilder->expr()->in($associationAlias.'.'.$associationField, ':'.$valueParameter)) ->setParameter($valueParameter, $values); } } @@ -184,41 +165,70 @@ protected function filterProperty(string $property, $value, QueryBuilder $queryB * * @throws InvalidArgumentException If strategy does not exist */ - protected function addWhereByStrategy(string $strategy, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $alias, string $field, $value, bool $caseSensitive) + protected function addWhereByStrategy(string $strategy, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $alias, string $field, $values, bool $caseSensitive) { + if (!\is_array($values)) { + $values = [$values]; + } + $wrapCase = $this->createWrapCase($caseSensitive); - $valueParameter = $queryNameGenerator->generateParameterName($field); + $valueParameter = ':'.$queryNameGenerator->generateParameterName($field); + $aliasedField = sprintf('%s.%s', $alias, $field); - switch ($strategy) { - case null: - case self::STRATEGY_EXACT: + if (null == $strategy || self::STRATEGY_EXACT == $strategy) { + if (1 == \count($values)) { $queryBuilder - ->andWhere(sprintf($wrapCase('%s.%s').' = '.$wrapCase(':%s'), $alias, $field, $valueParameter)) - ->setParameter($valueParameter, $value); - break; - case self::STRATEGY_PARTIAL: - $queryBuilder - ->andWhere(sprintf($wrapCase('%s.%s').' LIKE '.$wrapCase('CONCAT(\'%%\', :%s, \'%%\')'), $alias, $field, $valueParameter)) - ->setParameter($valueParameter, $value); - break; - case self::STRATEGY_START: - $queryBuilder - ->andWhere(sprintf($wrapCase('%s.%s').' LIKE '.$wrapCase('CONCAT(:%s, \'%%\')'), $alias, $field, $valueParameter)) - ->setParameter($valueParameter, $value); - break; - case self::STRATEGY_END: - $queryBuilder - ->andWhere(sprintf($wrapCase('%s.%s').' LIKE '.$wrapCase('CONCAT(\'%%\', :%s)'), $alias, $field, $valueParameter)) - ->setParameter($valueParameter, $value); - break; - case self::STRATEGY_WORD_START: - $queryBuilder - ->andWhere(sprintf($wrapCase('%1$s.%2$s').' LIKE '.$wrapCase('CONCAT(:%3$s, \'%%\')').' OR '.$wrapCase('%1$s.%2$s').' LIKE '.$wrapCase('CONCAT(\'%% \', :%3$s, \'%%\')'), $alias, $field, $valueParameter)) - ->setParameter($valueParameter, $value); - break; - default: - throw new InvalidArgumentException(sprintf('strategy %s does not exist.', $strategy)); + ->andWhere($queryBuilder->expr()->eq($wrapCase($aliasedField), $wrapCase($valueParameter))) + ->setParameter($valueParameter, $values[0]); + + return; + } + + $queryBuilder + ->andWhere($queryBuilder->expr()->in($wrapCase($aliasedField), $valueParameter)) + ->setParameter($valueParameter, $caseSensitive ? $values : array_map('strtolower', $values)); + + return; } + + $ors = []; + $parameters = []; + foreach ($values as $key => $value) { + $keyValueParameter = sprintf('%s_%s', $valueParameter, $key); + $parameters[$caseSensitive ? $value : strtolower($value)] = $keyValueParameter; + + switch ($strategy) { + case self::STRATEGY_PARTIAL: + $ors[] = $queryBuilder->expr()->like( + $wrapCase($aliasedField), + $wrapCase((string) $queryBuilder->expr()->concat("'%'", $keyValueParameter, "'%'")) + ); + break; + case self::STRATEGY_START: + $ors[] = $queryBuilder->expr()->like( + $wrapCase($aliasedField), + $wrapCase((string) $queryBuilder->expr()->concat($keyValueParameter, "'%'")) + ); + break; + case self::STRATEGY_END: + $ors[] = $queryBuilder->expr()->like( + $wrapCase($aliasedField), + $wrapCase((string) $queryBuilder->expr()->concat("'%'", $keyValueParameter)) + ); + break; + case self::STRATEGY_WORD_START: + $ors[] = $queryBuilder->expr()->orX( + $queryBuilder->expr()->like($wrapCase($aliasedField), $wrapCase((string) $queryBuilder->expr()->concat($keyValueParameter, "'%'"))), + $queryBuilder->expr()->like($wrapCase($aliasedField), $wrapCase((string) $queryBuilder->expr()->concat("'% '", $keyValueParameter, "'%'"))) + ); + break; + default: + throw new InvalidArgumentException(sprintf('strategy %s does not exist.', $strategy)); + } + } + + $queryBuilder->andWhere($queryBuilder->expr()->orX(...$ors)); + array_walk($parameters, [$queryBuilder, 'setParameter']); } /** diff --git a/tests/Bridge/Doctrine/Common/Filter/SearchFilterTestTrait.php b/tests/Bridge/Doctrine/Common/Filter/SearchFilterTestTrait.php index 1cc6e81420e..06060199848 100644 --- a/tests/Bridge/Doctrine/Common/Filter/SearchFilterTestTrait.php +++ b/tests/Bridge/Doctrine/Common/Filter/SearchFilterTestTrait.php @@ -264,6 +264,30 @@ private function provideApplyTestArguments(): array 'name' => 'partial', ], ], + 'partial (multiple values)' => [ + [ + 'id' => null, + 'name' => 'partial', + ], + [ + 'name' => [ + 'CaSE', + 'SENSitive', + ], + ], + ], + 'partial (multiple values; case insensitive)' => [ + [ + 'id' => null, + 'name' => 'ipartial', + ], + [ + 'name' => [ + 'CaSE', + 'inSENSitive', + ], + ], + ], 'start' => [ [ 'id' => null, @@ -282,6 +306,30 @@ private function provideApplyTestArguments(): array 'name' => 'partial', ], ], + 'start (multiple values)' => [ + [ + 'id' => null, + 'name' => 'start', + ], + [ + 'name' => [ + 'CaSE', + 'SENSitive', + ], + ], + ], + 'start (multiple values; case insensitive)' => [ + [ + 'id' => null, + 'name' => 'istart', + ], + [ + 'name' => [ + 'CaSE', + 'inSENSitive', + ], + ], + ], 'end' => [ [ 'id' => null, @@ -300,6 +348,30 @@ private function provideApplyTestArguments(): array 'name' => 'partial', ], ], + 'end (multiple values)' => [ + [ + 'id' => null, + 'name' => 'end', + ], + [ + 'name' => [ + 'CaSE', + 'SENSitive', + ], + ], + ], + 'end (multiple values; case insensitive)' => [ + [ + 'id' => null, + 'name' => 'iend', + ], + [ + 'name' => [ + 'CaSE', + 'inSENSitive', + ], + ], + ], 'word_start' => [ [ 'id' => null, @@ -318,6 +390,30 @@ private function provideApplyTestArguments(): array 'name' => 'partial', ], ], + 'word_start (multiple values)' => [ + [ + 'id' => null, + 'name' => 'word_start', + ], + [ + 'name' => [ + 'CaSE', + 'SENSitive', + ], + ], + ], + 'word_start (multiple values; case insensitive)' => [ + [ + 'id' => null, + 'name' => 'iword_start', + ], + [ + 'name' => [ + 'CaSE', + 'inSENSitive', + ], + ], + ], 'invalid value for relation' => [ [ 'id' => null, diff --git a/tests/Bridge/Doctrine/Orm/Filter/SearchFilterTest.php b/tests/Bridge/Doctrine/Orm/Filter/SearchFilterTest.php index c7aa2bc0ec4..3e411ec5e99 100644 --- a/tests/Bridge/Doctrine/Orm/Filter/SearchFilterTest.php +++ b/tests/Bridge/Doctrine/Orm/Filter/SearchFilterTest.php @@ -385,7 +385,7 @@ public function provideApplyTestData(): array $filterFactory, ], 'exact (multiple values)' => [ - sprintf('SELECT %s FROM %s %1$s WHERE %1$s.name IN (:name_p1)', $this->alias, Dummy::class), + sprintf('SELECT %s FROM %s %1$s WHERE %1$s.name IN(:name_p1)', $this->alias, Dummy::class), [ 'name_p1' => [ 'CaSE', @@ -395,7 +395,7 @@ public function provideApplyTestData(): array $filterFactory, ], 'exact (multiple values; case insensitive)' => [ - sprintf('SELECT %s FROM %s %1$s WHERE LOWER(%1$s.name) IN (:name_p1)', $this->alias, Dummy::class), + sprintf('SELECT %s FROM %s %1$s WHERE LOWER(%1$s.name) IN(:name_p1)', $this->alias, Dummy::class), [ 'name_p1' => [ 'case', @@ -415,43 +415,107 @@ public function provideApplyTestData(): array $filterFactory, ], 'partial' => [ - sprintf('SELECT %s FROM %s %1$s WHERE %1$s.name LIKE CONCAT(\'%%\', :name_p1, \'%%\')', $this->alias, Dummy::class), - ['name_p1' => 'partial'], + sprintf('SELECT %s FROM %s %1$s WHERE %1$s.name LIKE CONCAT(\'%%\', :name_p1_0, \'%%\')', $this->alias, Dummy::class), + ['name_p1_0' => 'partial'], $filterFactory, ], 'partial (case insensitive)' => [ - sprintf('SELECT %s FROM %s %1$s WHERE LOWER(%1$s.name) LIKE LOWER(CONCAT(\'%%\', :name_p1, \'%%\'))', $this->alias, Dummy::class), - ['name_p1' => 'partial'], + sprintf('SELECT %s FROM %s %1$s WHERE LOWER(%1$s.name) LIKE LOWER(CONCAT(\'%%\', :name_p1_0, \'%%\'))', $this->alias, Dummy::class), + ['name_p1_0' => 'partial'], + $filterFactory, + ], + 'partial (multiple values)' => [ + sprintf('SELECT %s FROM %s %1$s WHERE %1$s.name LIKE CONCAT(\'%%\', :name_p1_0, \'%%\') OR %1$s.name LIKE CONCAT(\'%%\', :name_p1_1, \'%%\')', $this->alias, Dummy::class), + [ + 'name_p1_0' => 'CaSE', + 'name_p1_1' => 'SENSitive', + ], + $filterFactory, + ], + 'partial (multiple values; case insensitive)' => [ + sprintf('SELECT %s FROM %s %1$s WHERE LOWER(%1$s.name) LIKE LOWER(CONCAT(\'%%\', :name_p1_0, \'%%\')) OR LOWER(%1$s.name) LIKE LOWER(CONCAT(\'%%\', :name_p1_1, \'%%\'))', $this->alias, Dummy::class), + [ + 'name_p1_0' => 'case', + 'name_p1_1' => 'insensitive', + ], $filterFactory, ], 'start' => [ - sprintf('SELECT %s FROM %s %1$s WHERE %1$s.name LIKE CONCAT(:name_p1, \'%%\')', $this->alias, Dummy::class), - ['name_p1' => 'partial'], + sprintf('SELECT %s FROM %s %1$s WHERE %1$s.name LIKE CONCAT(:name_p1_0, \'%%\')', $this->alias, Dummy::class), + ['name_p1_0' => 'partial'], $filterFactory, ], 'start (case insensitive)' => [ - sprintf('SELECT %s FROM %s %1$s WHERE LOWER(%1$s.name) LIKE LOWER(CONCAT(:name_p1, \'%%\'))', $this->alias, Dummy::class), - ['name_p1' => 'partial'], + sprintf('SELECT %s FROM %s %1$s WHERE LOWER(%1$s.name) LIKE LOWER(CONCAT(:name_p1_0, \'%%\'))', $this->alias, Dummy::class), + ['name_p1_0' => 'partial'], + $filterFactory, + ], + 'start (multiple values)' => [ + sprintf('SELECT %s FROM %s %1$s WHERE %1$s.name LIKE CONCAT(:name_p1_0, \'%%\') OR %1$s.name LIKE CONCAT(:name_p1_1, \'%%\')', $this->alias, Dummy::class), + [ + 'name_p1_0' => 'CaSE', + 'name_p1_1' => 'SENSitive', + ], + $filterFactory, + ], + 'start (multiple values; case insensitive)' => [ + sprintf('SELECT %s FROM %s %1$s WHERE LOWER(%1$s.name) LIKE LOWER(CONCAT(:name_p1_0, \'%%\')) OR LOWER(%1$s.name) LIKE LOWER(CONCAT(:name_p1_1, \'%%\'))', $this->alias, Dummy::class), + [ + 'name_p1_0' => 'case', + 'name_p1_1' => 'insensitive', + ], $filterFactory, ], 'end' => [ - sprintf('SELECT %s FROM %s %1$s WHERE %1$s.name LIKE CONCAT(\'%%\', :name_p1)', $this->alias, Dummy::class), - ['name_p1' => 'partial'], + sprintf('SELECT %s FROM %s %1$s WHERE %1$s.name LIKE CONCAT(\'%%\', :name_p1_0)', $this->alias, Dummy::class), + ['name_p1_0' => 'partial'], $filterFactory, ], 'end (case insensitive)' => [ - sprintf('SELECT %s FROM %s %1$s WHERE LOWER(%1$s.name) LIKE LOWER(CONCAT(\'%%\', :name_p1))', $this->alias, Dummy::class), - ['name_p1' => 'partial'], + sprintf('SELECT %s FROM %s %1$s WHERE LOWER(%1$s.name) LIKE LOWER(CONCAT(\'%%\', :name_p1_0))', $this->alias, Dummy::class), + ['name_p1_0' => 'partial'], + $filterFactory, + ], + 'end (multiple values)' => [ + sprintf('SELECT %s FROM %s %1$s WHERE %1$s.name LIKE CONCAT(\'%%\', :name_p1_0) OR %1$s.name LIKE CONCAT(\'%%\', :name_p1_1)', $this->alias, Dummy::class), + [ + 'name_p1_0' => 'CaSE', + 'name_p1_1' => 'SENSitive', + ], + $filterFactory, + ], + 'end (multiple values; case insensitive)' => [ + sprintf('SELECT %s FROM %s %1$s WHERE LOWER(%1$s.name) LIKE LOWER(CONCAT(\'%%\', :name_p1_0)) OR LOWER(%1$s.name) LIKE LOWER(CONCAT(\'%%\', :name_p1_1))', $this->alias, Dummy::class), + [ + 'name_p1_0' => 'case', + 'name_p1_1' => 'insensitive', + ], $filterFactory, ], 'word_start' => [ - sprintf('SELECT %s FROM %s %1$s WHERE %1$s.name LIKE CONCAT(:name_p1, \'%%\') OR %1$s.name LIKE CONCAT(\'%% \', :name_p1, \'%%\')', $this->alias, Dummy::class), - ['name_p1' => 'partial'], + sprintf('SELECT %s FROM %s %1$s WHERE %1$s.name LIKE CONCAT(:name_p1_0, \'%%\') OR %1$s.name LIKE CONCAT(\'%% \', :name_p1_0, \'%%\')', $this->alias, Dummy::class), + ['name_p1_0' => 'partial'], $filterFactory, ], 'word_start (case insensitive)' => [ - sprintf('SELECT %s FROM %s %1$s WHERE LOWER(%1$s.name) LIKE LOWER(CONCAT(:name_p1, \'%%\')) OR LOWER(%1$s.name) LIKE LOWER(CONCAT(\'%% \', :name_p1, \'%%\'))', $this->alias, Dummy::class), - ['name_p1' => 'partial'], + sprintf('SELECT %s FROM %s %1$s WHERE LOWER(%1$s.name) LIKE LOWER(CONCAT(:name_p1_0, \'%%\')) OR LOWER(%1$s.name) LIKE LOWER(CONCAT(\'%% \', :name_p1_0, \'%%\'))', $this->alias, Dummy::class), + ['name_p1_0' => 'partial'], + $filterFactory, + ], + 'word_start (multiple values)' => [ + sprintf('SELECT %s FROM %s %1$s WHERE (%1$s.name LIKE CONCAT(:name_p1_0, \'%%\') OR %1$s.name LIKE CONCAT(\'%% \', :name_p1_0, \'%%\')) OR (%1$s.name LIKE CONCAT(:name_p1_1, \'%%\') OR %1$s.name LIKE CONCAT(\'%% \', :name_p1_1, \'%%\'))', $this->alias, Dummy::class), + [ + 'name_p1_0' => 'CaSE', + 'name_p1_1' => 'SENSitive', + ], + $filterFactory, + ], + 'word_start (multiple values; case insensitive)' => [ + sprintf('SELECT %s FROM %s %1$s WHERE (LOWER(%1$s.name) LIKE LOWER(CONCAT(:name_p1_0, \'%%\')) OR LOWER(%1$s.name) LIKE LOWER(CONCAT(\'%% \', :name_p1_0, \'%%\'))) OR (LOWER(%1$s.name) LIKE LOWER(CONCAT(:name_p1_1, \'%%\')) OR LOWER(%1$s.name) LIKE LOWER(CONCAT(\'%% \', :name_p1_1, \'%%\')))', $this->alias, Dummy::class), + [ + 'name_p1_0' => 'case', + 'name_p1_1' => 'insensitive', + ], $filterFactory, ], 'invalid value for relation' => [ @@ -478,7 +542,7 @@ public function provideApplyTestData(): array $filterFactory, ], 'mixed IRI and entity ID values for relations' => [ - sprintf('SELECT %s FROM %s %1$s INNER JOIN %1$s.relatedDummies relatedDummies_a1 WHERE %1$s.relatedDummy IN (:relatedDummy_p1) AND relatedDummies_a1.id = :relatedDummies_p2', $this->alias, Dummy::class), + sprintf('SELECT %s FROM %s %1$s INNER JOIN %1$s.relatedDummies relatedDummies_a1 WHERE %1$s.relatedDummy IN(:relatedDummy_p1) AND relatedDummies_a1.id = :relatedDummies_p2', $this->alias, Dummy::class), [ 'relatedDummy_p1' => [1, 2], 'relatedDummies_p2' => 1, From 347a0952982e053bb864548c757a22b6bb28fc24 Mon Sep 17 00:00:00 2001 From: Marcel Malberg Date: Wed, 18 Nov 2020 09:01:26 +0100 Subject: [PATCH 139/160] Serializer: Support ignore annotation (#3820) * Add support for @Ignore annotatiob * Fix null classMetadataFactory and cs issue * Fix php71 compat, simplify flow * cs fix * Add a test * skip test if ignore is not available Co-authored-by: Alexander Janssen --- src/Serializer/AbstractItemNormalizer.php | 11 +++ .../Serializer/AbstractItemNormalizerTest.php | 67 +++++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/src/Serializer/AbstractItemNormalizer.php b/src/Serializer/AbstractItemNormalizer.php index d49f4a21689..e30f9eab9fa 100644 --- a/src/Serializer/AbstractItemNormalizer.php +++ b/src/Serializer/AbstractItemNormalizer.php @@ -355,8 +355,19 @@ protected function getAllowedAttributes($classOrObject, array $context, $attribu $options = $this->getFactoryOptions($context); $propertyNames = $this->propertyNameCollectionFactory->create($context['resource_class'], $options); + $attributesMetadata = $this->classMetadataFactory ? + $this->classMetadataFactory->getMetadataFor($context['resource_class'])->getAttributesMetadata() : + null; + $allowedAttributes = []; foreach ($propertyNames as $propertyName) { + if ( + null != $attributesMetadata && \array_key_exists($propertyName, $attributesMetadata) && + method_exists($attributesMetadata[$propertyName], 'isIgnored') && + $attributesMetadata[$propertyName]->isIgnored()) { + continue; + } + $propertyMetadata = $this->propertyMetadataFactory->create($context['resource_class'], $propertyName, $options); if ( diff --git a/tests/Serializer/AbstractItemNormalizerTest.php b/tests/Serializer/AbstractItemNormalizerTest.php index 7f81ac608df..e4a6c491474 100644 --- a/tests/Serializer/AbstractItemNormalizerTest.php +++ b/tests/Serializer/AbstractItemNormalizerTest.php @@ -44,6 +44,9 @@ use Symfony\Component\PropertyAccess\PropertyAccessorInterface; use Symfony\Component\PropertyInfo\Type; use Symfony\Component\Serializer\Exception\UnexpectedValueException; +use Symfony\Component\Serializer\Mapping\AttributeMetadata; +use Symfony\Component\Serializer\Mapping\ClassMetadataInterface; +use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface; use Symfony\Component\Serializer\NameConverter\AdvancedNameConverterInterface; use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer; use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; @@ -1202,4 +1205,68 @@ public function testNormalizationWithDataTransformer() $propertyAccessorProphecy->setValue($actualDummy, 'name', 'Dummy')->shouldHaveBeenCalled(); } + + public function testNormalizationWithIgnoreMetadata() + { + if (!method_exists(AttributeMetadata::class, 'setIgnore')) { + $this->markTestSkipped(); + } + + $dummy = new Dummy(); + + $dummyAttributeMetadata = new AttributeMetadata('dummy'); + $dummyAttributeMetadata->setIgnore(true); + + $classMetadataProphecy = $this->prophesize(ClassMetadataInterface::class); + $classMetadataProphecy->getAttributesMetadata()->willReturn(['dummy' => $dummyAttributeMetadata]); + + $classMetadataFactoryProphecy = $this->prophesize(ClassMetadataFactoryInterface::class); + $classMetadataFactoryProphecy->getMetadataFor(Dummy::class)->willReturn($classMetadataProphecy->reveal()); + + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(Dummy::class, [])->willReturn(new PropertyNameCollection(['name', 'dummy'])); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', [])->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING), '', true)); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'dummy', [])->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING), '', true)); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getIriFromItem($dummy)->willReturn('/dummies/1'); + + $propertyAccessorProphecy = $this->prophesize(PropertyAccessorInterface::class); + $propertyAccessorProphecy->getValue($dummy, 'name')->willReturn('foo'); + $propertyAccessorProphecy->getValue($dummy, 'dummy')->willReturn('bar'); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass($dummy, null)->willReturn(Dummy::class); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->willImplement(NormalizerInterface::class); + $serializerProphecy->normalize('foo', null, Argument::type('array'))->willReturn('foo'); + $serializerProphecy->normalize('bar', null, Argument::type('array'))->willReturn('bar'); + + $normalizer = $this->getMockForAbstractClass(AbstractItemNormalizer::class, [ + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal(), + $propertyAccessorProphecy->reveal(), + null, + $classMetadataFactoryProphecy->reveal(), + null, + false, + [], + [], + null, + null, + ]); + $normalizer->setSerializer($serializerProphecy->reveal()); + + $expected = [ + 'name' => 'foo', + ]; + $this->assertEquals($expected, $normalizer->normalize($dummy, null, [ + 'resources' => [], + ])); + } } From fcb337670db0b0493905e4641bf91693cc38de74 Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 19 Nov 2020 11:10:57 +0100 Subject: [PATCH 140/160] Subresource definition ADR proposal --- docs/adr/0000-subresources-definition.md | 112 +++++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 docs/adr/0000-subresources-definition.md diff --git a/docs/adr/0000-subresources-definition.md b/docs/adr/0000-subresources-definition.md new file mode 100644 index 00000000000..2e7a9db0307 --- /dev/null +++ b/docs/adr/0000-subresources-definition.md @@ -0,0 +1,112 @@ +# Subresource definition + +* Status: proposed +* Deciders: @dunglas, @vincentchalamon, @soyuka, @GregoireHebert, @Deuchnord + +## Context and Problem Statement + +Subresources introduced in 2017 (#904) introduced the `ApiSubresource` annotation. This definition came along with its own set of issues (#2706) and needs a refreshment. On top of that, write support on subresources is a wanted feature and it is hard to implement currently (#2598) (See [0001-subresource-write-support](./0001-subresource-write-support.md)). How can we revamp subresources to improve the developer experience and reduce the complexity? + +## Considered Options + +* Fix the actual `ApiSubresource` annotation +* Use multiple `ApiResource` to declare subresources and deprecate `ApiSubresource` +* Deprecate subresources + +## Decision Outcome + +We choose to use multiple `ApiResource` annotations to declare subresources on a given Model class: + +* Subresource declaration is an important feature and removing it would harm the software. +* The `ApiSubresource` annotation is declared on a Model's properties, which was identified as the root of several issues. For example, finding what class it is defined on (#3458). Having multiple `ApiResource` would improve a lot the declaration of our internal metadata and would cause less confusion for developers. +* The `path` of these multiple `ApiResource` needs to be implicitly described. + +### Examples + +A Company resource with a Company Users subresource on (`/companies/1/users`); + +```php +/** + * @ApiResource() + * @ApiResource(path="/companies/{companyId}/users") + */ +class Company { + public int $id; + public array $users = []; +} +``` + +With explicit identifiers: + +```php +/** + * @ApiResource() + * @ApiResource(path="/companies/{companyId}/users", identifiers={"companyId": {Company::class, "id"}}) + */ +class Company { + public int $id; + public array $users = []; +} +``` + +Two-level subresource to get the users belonging to the company 1 located in France `/countries/fr/companies/1/users`: + +```php +/** + * @ApiResource() + * @ApiResource(path="/countries/{countryId}/companies/{companyId}/users") + */ +class Country { + public int $id; + public array $companies = []; +} +``` + +With explicit identifiers: + +```php +/** + * @ApiResource() + * @ApiResource(path="/countries/{countryId}/companies/{companyId}/users", identifiers={"companyId": {Company::class, "id"}, "countryId": {Country::class, "shortName"}}) + */ +class Country { + public string $shortName; + public array $companies = []; +} +``` + +Get the company employees or administrators `/companies/1/administrators`: + +```php +/** + * @ApiResource() + * @ApiResource(path="/companies/{companyId}/administrators") + * @ApiResource(path="/companies/{companyId}/employees") + */ +class Company { + public int $id; + public Users $employees; + public Users $administrators; +} +``` + +With explicit identifiers: + +```php +/** + * @ApiResource() + * @ApiResource(path="/companies/{companyId}/administrators", identifiers={"companyId": {Company::class, "id"}, "*": {Company::class, "administrators"}}) + * @ApiResource(path="/companies/{companyId}/employees", identifiers={"companyId": {Company::class, "id"}, "*": {Company::class, "employees"}}) + */ +class Company { + public int $id; + public Users $employees; + public Users $administrators; +} +``` + +## TODO: + +* Without explicit identifiers, how do we map `companyId` to Company->id ? +* Do we parse the path to find `administrators` and map it to the property ? +* The Tuple `identifiers={pathParameter: {Class, property}}` should be redefined / validated (and what about `*` for collection?) From a18abb35c55955b75151d2d9637f6be97c6e4a38 Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 19 Nov 2020 11:23:05 +0100 Subject: [PATCH 141/160] fix links --- docs/adr/0000-subresources-definition.md | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/docs/adr/0000-subresources-definition.md b/docs/adr/0000-subresources-definition.md index 2e7a9db0307..0949dc847ea 100644 --- a/docs/adr/0000-subresources-definition.md +++ b/docs/adr/0000-subresources-definition.md @@ -5,7 +5,7 @@ ## Context and Problem Statement -Subresources introduced in 2017 (#904) introduced the `ApiSubresource` annotation. This definition came along with its own set of issues (#2706) and needs a refreshment. On top of that, write support on subresources is a wanted feature and it is hard to implement currently (#2598) (See [0001-subresource-write-support](./0001-subresource-write-support.md)). How can we revamp subresources to improve the developer experience and reduce the complexity? +Subresources introduced in 2017 ([#904][pull/904]) introduced the `ApiSubresource` annotation. This definition came along with its own set of issues ([#2706][issue/2706]) and needs a refreshment. On top of that, write support on subresources is a wanted feature and it is hard to implement currently ([#2598][pull/2598]) (See [0001-subresource-write-support](./0001-subresource-write-support.md)). How can we revamp subresources to improve the developer experience and reduce the complexity? ## Considered Options @@ -18,7 +18,7 @@ Subresources introduced in 2017 (#904) introduced the `ApiSubresource` annotatio We choose to use multiple `ApiResource` annotations to declare subresources on a given Model class: * Subresource declaration is an important feature and removing it would harm the software. -* The `ApiSubresource` annotation is declared on a Model's properties, which was identified as the root of several issues. For example, finding what class it is defined on (#3458). Having multiple `ApiResource` would improve a lot the declaration of our internal metadata and would cause less confusion for developers. +* The `ApiSubresource` annotation is declared on a Model's properties, which was identified as the root of several issues. For example, finding what class it is defined on ([#3458][issue/3458]). Having multiple `ApiResource` would improve a lot the declaration of our internal metadata and would cause less confusion for developers. * The `path` of these multiple `ApiResource` needs to be implicitly described. ### Examples @@ -110,3 +110,14 @@ class Company { * Without explicit identifiers, how do we map `companyId` to Company->id ? * Do we parse the path to find `administrators` and map it to the property ? * The Tuple `identifiers={pathParameter: {Class, property}}` should be redefined / validated (and what about `*` for collection?) + +## Links + +* [Subresource refactor][pull/3689] + + +[pull/904]: https://github.com/api-platform/core/pull/904 "Subresource feature" +[issue/2706]: https://github.com/api-platform/core/issues/2706 "Subresource RFC" +[pull/2598]: https://github.com/api-platform/core/pull/2598 "Subresource write support" +[issue/3458]: https://github.com/api-platform/core/pull/3458 "Subresource poor DX" +[pull/3689]: https://github.com/api-platform/core/pull/3689 "Revamp subresource" From 3af682c15a39444b263bfe27aac086983b99d5b4 Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 19 Nov 2020 12:17:05 +0100 Subject: [PATCH 142/160] Review --- docs/adr/0000-subresources-definition.md | 39 +++++++++++++++++------- 1 file changed, 28 insertions(+), 11 deletions(-) diff --git a/docs/adr/0000-subresources-definition.md b/docs/adr/0000-subresources-definition.md index 0949dc847ea..2a0496ef4c9 100644 --- a/docs/adr/0000-subresources-definition.md +++ b/docs/adr/0000-subresources-definition.md @@ -20,19 +20,19 @@ We choose to use multiple `ApiResource` annotations to declare subresources on a * Subresource declaration is an important feature and removing it would harm the software. * The `ApiSubresource` annotation is declared on a Model's properties, which was identified as the root of several issues. For example, finding what class it is defined on ([#3458][issue/3458]). Having multiple `ApiResource` would improve a lot the declaration of our internal metadata and would cause less confusion for developers. * The `path` of these multiple `ApiResource` needs to be implicitly described. +* An `ApiResource` is always defined on the Resource it represents: `/companies/1/users` outputs Users and should be defined on the `User` model. ### Examples -A Company resource with a Company Users subresource on (`/companies/1/users`); +Get Users belonging to the company on (`/companies/1/users`); ```php /** * @ApiResource() * @ApiResource(path="/companies/{companyId}/users") */ -class Company { - public int $id; - public array $users = []; +class User { + public array $companies = []; } ``` @@ -43,23 +43,25 @@ With explicit identifiers: * @ApiResource() * @ApiResource(path="/companies/{companyId}/users", identifiers={"companyId": {Company::class, "id"}}) */ -class Company { - public int $id; - public array $users = []; +class User { + public array $companies = []; } ``` -Two-level subresource to get the users belonging to the company 1 located in France `/countries/fr/companies/1/users`: +Two-level subresource to get the Users belonging to the Company #1 located in France `/countries/fr/companies/1/users`: ```php /** * @ApiResource() * @ApiResource(path="/countries/{countryId}/companies/{companyId}/users") */ -class Country { - public int $id; +class Users { public array $companies = []; } + +class Company { + public array $countries = []; +} ``` With explicit identifiers: @@ -69,9 +71,16 @@ With explicit identifiers: * @ApiResource() * @ApiResource(path="/countries/{countryId}/companies/{companyId}/users", identifiers={"companyId": {Company::class, "id"}, "countryId": {Country::class, "shortName"}}) */ +class Users { + public array $companies = []; +} + +class Company { + public array $countries = []; +} + class Country { public string $shortName; - public array $companies = []; } ``` @@ -83,6 +92,10 @@ Get the company employees or administrators `/companies/1/administrators`: * @ApiResource(path="/companies/{companyId}/administrators") * @ApiResource(path="/companies/{companyId}/employees") */ +class Users { + public array $companies; +} + class Company { public int $id; public Users $employees; @@ -98,6 +111,10 @@ With explicit identifiers: * @ApiResource(path="/companies/{companyId}/administrators", identifiers={"companyId": {Company::class, "id"}, "*": {Company::class, "administrators"}}) * @ApiResource(path="/companies/{companyId}/employees", identifiers={"companyId": {Company::class, "id"}, "*": {Company::class, "employees"}}) */ +class Users { + public array $companies; +} + class Company { public int $id; public Users $employees; From 1ddd7c6bd3109cf382d60f45ca827f95427a95b9 Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 19 Nov 2020 12:18:48 +0100 Subject: [PATCH 143/160] Review --- docs/adr/0000-subresources-definition.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/adr/0000-subresources-definition.md b/docs/adr/0000-subresources-definition.md index 2a0496ef4c9..8f77e8e4a53 100644 --- a/docs/adr/0000-subresources-definition.md +++ b/docs/adr/0000-subresources-definition.md @@ -5,7 +5,7 @@ ## Context and Problem Statement -Subresources introduced in 2017 ([#904][pull/904]) introduced the `ApiSubresource` annotation. This definition came along with its own set of issues ([#2706][issue/2706]) and needs a refreshment. On top of that, write support on subresources is a wanted feature and it is hard to implement currently ([#2598][pull/2598]) (See [0001-subresource-write-support](./0001-subresource-write-support.md)). How can we revamp subresources to improve the developer experience and reduce the complexity? +Subresources introduced in 2017 ([#904][pull/904]) the `ApiSubresource` annotation. This definition came along with its own set of issues ([#2706][issue/2706]) and needs a refreshment. On top of that, write support on subresources is a wanted feature and it is hard to implement currently ([#2598][pull/2598]) (See [0001-subresource-write-support](./0001-subresource-write-support.md)). How can we revamp the Subresource definition to improve the developer experience and reduce the complexity? ## Considered Options @@ -28,7 +28,7 @@ Get Users belonging to the company on (`/companies/1/users`); ```php /** - * @ApiResource() + * @ApiResource(path="/users") * @ApiResource(path="/companies/{companyId}/users") */ class User { @@ -40,7 +40,7 @@ With explicit identifiers: ```php /** - * @ApiResource() + * @ApiResource(path="/users") * @ApiResource(path="/companies/{companyId}/users", identifiers={"companyId": {Company::class, "id"}}) */ class User { @@ -52,7 +52,7 @@ Two-level subresource to get the Users belonging to the Company #1 located in Fr ```php /** - * @ApiResource() + * @ApiResource(path="/users") * @ApiResource(path="/countries/{countryId}/companies/{companyId}/users") */ class Users { @@ -68,7 +68,7 @@ With explicit identifiers: ```php /** - * @ApiResource() + * @ApiResource(path="/users") * @ApiResource(path="/countries/{countryId}/companies/{companyId}/users", identifiers={"companyId": {Company::class, "id"}, "countryId": {Country::class, "shortName"}}) */ class Users { @@ -88,7 +88,7 @@ Get the company employees or administrators `/companies/1/administrators`: ```php /** - * @ApiResource() + * @ApiResource(path="/users") * @ApiResource(path="/companies/{companyId}/administrators") * @ApiResource(path="/companies/{companyId}/employees") */ @@ -107,7 +107,7 @@ With explicit identifiers: ```php /** - * @ApiResource() + * @ApiResource(path="/users") * @ApiResource(path="/companies/{companyId}/administrators", identifiers={"companyId": {Company::class, "id"}, "*": {Company::class, "administrators"}}) * @ApiResource(path="/companies/{companyId}/employees", identifiers={"companyId": {Company::class, "id"}, "*": {Company::class, "employees"}}) */ From 6350ea095e418c3c883baffd13a891e70024efa1 Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 19 Nov 2020 12:37:01 +0100 Subject: [PATCH 144/160] Review --- docs/adr/0000-subresources-definition.md | 85 ++++++++++++++---------- 1 file changed, 51 insertions(+), 34 deletions(-) diff --git a/docs/adr/0000-subresources-definition.md b/docs/adr/0000-subresources-definition.md index 8f77e8e4a53..88ff47a91a0 100644 --- a/docs/adr/0000-subresources-definition.md +++ b/docs/adr/0000-subresources-definition.md @@ -5,7 +5,7 @@ ## Context and Problem Statement -Subresources introduced in 2017 ([#904][pull/904]) the `ApiSubresource` annotation. This definition came along with its own set of issues ([#2706][issue/2706]) and needs a refreshment. On top of that, write support on subresources is a wanted feature and it is hard to implement currently ([#2598][pull/2598]) (See [0001-subresource-write-support](./0001-subresource-write-support.md)). How can we revamp the Subresource definition to improve the developer experience and reduce the complexity? +Subresources introduced in 2017 ([#904][pull/904]) the `ApiSubresource` annotation. This definition came along with its own set of issues ([#2706][issue/2706]) and needs a refreshment. On top of that, write support on subresources is a wanted feature and it is hard to implement currently ([#2598][pull/2598]) (See [ADR-0001-subresource-write-support](./0001-subresource-write-support.md)). How can we revamp the Subresource definition to improve the developer experience and reduce the complexity? ## Considered Options @@ -21,6 +21,7 @@ We choose to use multiple `ApiResource` annotations to declare subresources on a * The `ApiSubresource` annotation is declared on a Model's properties, which was identified as the root of several issues. For example, finding what class it is defined on ([#3458][issue/3458]). Having multiple `ApiResource` would improve a lot the declaration of our internal metadata and would cause less confusion for developers. * The `path` of these multiple `ApiResource` needs to be implicitly described. * An `ApiResource` is always defined on the Resource it represents: `/companies/1/users` outputs Users and should be defined on the `User` model. +* PropertyInfo and Doctrine metadata can be used to define how is the Resource identified according to the given path. ### Examples @@ -32,18 +33,26 @@ Get Users belonging to the company on (`/companies/1/users`); * @ApiResource(path="/companies/{companyId}/users") */ class User { + /** @ApiProperty(identifier=true) */ + public int $id; + + /** @var Company[] */ public array $companies = []; } ``` -With explicit identifiers: +With explicit identifiers, the tuple is explained in [ADR-0002-identifiers](./0002-identifiers) `{parameterName: {Class, property}}`: ```php /** - * @ApiResource(path="/users") - * @ApiResource(path="/companies/{companyId}/users", identifiers={"companyId": {Company::class, "id"}}) + * @ApiResource(path="/users", identifiers={"id": {User::class, "id"}}) + * @ApiResource(path="/companies/{companyId}/users", identifiers={"companyId": {Company::class, "id"}, "id": {User::class, "id"}}) */ class User { + /** @ApiProperty(identifier=true) */ + public int $id; + + /** @var Company[] */ public array $companies = []; } ``` @@ -55,31 +64,53 @@ Two-level subresource to get the Users belonging to the Company #1 located in Fr * @ApiResource(path="/users") * @ApiResource(path="/countries/{countryId}/companies/{companyId}/users") */ -class Users { +class User { + /** @ApiProperty(identifier=true) */ + public int $id; + + /** @var Company[] */ public array $companies = []; } class Company { + /** @ApiProperty(identifier=true) */ + public int $id; + + /** @var Country[] **/ public array $countries = []; } + +class Country { + /** @ApiProperty(identifier=true) */ + public string $shortName; +} ``` With explicit identifiers: ```php /** - * @ApiResource(path="/users") - * @ApiResource(path="/countries/{countryId}/companies/{companyId}/users", identifiers={"companyId": {Company::class, "id"}, "countryId": {Country::class, "shortName"}}) + * @ApiResource(path="/users", identifiers={"id": {User::class, "id"}}) + * @ApiResource(path="/countries/{countryId}/companies/{companyId}/users", identifiers={"companyId": {Company::class, "id"}, "countryId": {Country::class, "shortName"}, "id": {User::class, "id"}}) */ -class Users { +class User { + /** @ApiProperty(identifier=true) */ + public int $id; + + /** @var Company[] */ public array $companies = []; } class Company { + /** @ApiProperty(identifier=true) */ + public int $id; + + /** @var Country[] **/ public array $countries = []; } class Country { + /** @ApiProperty(identifier=true) */ public string $shortName; } ``` @@ -92,41 +123,27 @@ Get the company employees or administrators `/companies/1/administrators`: * @ApiResource(path="/companies/{companyId}/administrators") * @ApiResource(path="/companies/{companyId}/employees") */ -class Users { - public array $companies; -} - -class Company { +class User { + /** @ApiProperty(identifier=true) */ public int $id; - public Users $employees; - public Users $administrators; -} -``` - -With explicit identifiers: -```php -/** - * @ApiResource(path="/users") - * @ApiResource(path="/companies/{companyId}/administrators", identifiers={"companyId": {Company::class, "id"}, "*": {Company::class, "administrators"}}) - * @ApiResource(path="/companies/{companyId}/employees", identifiers={"companyId": {Company::class, "id"}, "*": {Company::class, "employees"}}) - */ -class Users { - public array $companies; + /** @var Company[] */ + public array $companies = []; } class Company { + /** @ApiProperty(identifier=true) */ public int $id; - public Users $employees; - public Users $administrators; + + /** @var User[] **/ + public array $employees; + + /** @var User[] **/ + public array $administrators; } ``` -## TODO: - -* Without explicit identifiers, how do we map `companyId` to Company->id ? -* Do we parse the path to find `administrators` and map it to the property ? -* The Tuple `identifiers={pathParameter: {Class, property}}` should be redefined / validated (and what about `*` for collection?) +This example will require a custom DataProvider as the discriminator needs to be explicit. ## Links From 9d6fad40eb2d9a5a711cf8132c18b9182f8edd38 Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 19 Nov 2020 13:54:03 +0100 Subject: [PATCH 145/160] explicit --- docs/adr/0000-subresources-definition.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/adr/0000-subresources-definition.md b/docs/adr/0000-subresources-definition.md index 88ff47a91a0..6d047d50206 100644 --- a/docs/adr/0000-subresources-definition.md +++ b/docs/adr/0000-subresources-definition.md @@ -19,7 +19,7 @@ We choose to use multiple `ApiResource` annotations to declare subresources on a * Subresource declaration is an important feature and removing it would harm the software. * The `ApiSubresource` annotation is declared on a Model's properties, which was identified as the root of several issues. For example, finding what class it is defined on ([#3458][issue/3458]). Having multiple `ApiResource` would improve a lot the declaration of our internal metadata and would cause less confusion for developers. -* The `path` of these multiple `ApiResource` needs to be implicitly described. +* The `path` of these multiple `ApiResource` needs to be explicitly described. * An `ApiResource` is always defined on the Resource it represents: `/companies/1/users` outputs Users and should be defined on the `User` model. * PropertyInfo and Doctrine metadata can be used to define how is the Resource identified according to the given path. From 0ca717cf675a78a7a784b773de12b00f10a92a3c Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 19 Nov 2020 15:39:44 +0100 Subject: [PATCH 146/160] review --- docs/adr/0000-subresources-definition.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/adr/0000-subresources-definition.md b/docs/adr/0000-subresources-definition.md index 6d047d50206..0045548b601 100644 --- a/docs/adr/0000-subresources-definition.md +++ b/docs/adr/0000-subresources-definition.md @@ -9,7 +9,7 @@ Subresources introduced in 2017 ([#904][pull/904]) the `ApiSubresource` annotati ## Considered Options -* Fix the actual `ApiSubresource` annotation +* Fix the current `ApiSubresource` annotation * Use multiple `ApiResource` to declare subresources and deprecate `ApiSubresource` * Deprecate subresources From 621ef66ef7e603c1a74efa42adecc6b81fec897d Mon Sep 17 00:00:00 2001 From: Jan Christoph Beyer Date: Thu, 19 Nov 2020 11:09:19 +0100 Subject: [PATCH 147/160] * make all openapi info model fields configurable --- .../ApiPlatformExtension.php | 11 +++++ .../DependencyInjection/Configuration.php | 29 ++++++++++++ .../Bundle/Resources/config/openapi.xml | 6 +++ src/OpenApi/Factory/OpenApiFactory.php | 4 +- src/OpenApi/Model/Contact.php | 14 +++--- src/OpenApi/Model/License.php | 6 +-- src/OpenApi/Options.php | 46 ++++++++++++++++++- .../ApiPlatformExtensionTest.php | 6 +++ .../DependencyInjection/ConfigurationTest.php | 12 +++++ 9 files changed, 121 insertions(+), 13 deletions(-) diff --git a/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index e0a432999e1..04b9ff08090 100644 --- a/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -106,6 +106,7 @@ public function load(array $configs, ContainerBuilder $container): void $this->registerCommonConfiguration($container, $config, $loader, $formats, $patchFormats, $errorFormats); $this->registerMetadataConfiguration($container, $config, $loader); $this->registerOAuthConfiguration($container, $config); + $this->registerOpenApiConfiguration($container, $config); $this->registerSwaggerConfiguration($container, $config, $loader); $this->registerJsonApiConfiguration($formats, $loader); $this->registerJsonLdHydraConfiguration($container, $formats, $loader, $config['enable_docs']); @@ -710,6 +711,16 @@ private function registerSecurityConfiguration(ContainerBuilder $container, XmlF } } + private function registerOpenApiConfiguration(ContainerBuilder $container, array $config): void + { + $container->setParameter('api_platform.openapi.termsOfService', $config['openapi']['termsOfService']); + $container->setParameter('api_platform.openapi.contact.name', $config['openapi']['contact']['name']); + $container->setParameter('api_platform.openapi.contact.url', $config['openapi']['contact']['url']); + $container->setParameter('api_platform.openapi.contact.email', $config['openapi']['contact']['email']); + $container->setParameter('api_platform.openapi.license.name', $config['openapi']['license']['name']); + $container->setParameter('api_platform.openapi.license.url', $config['openapi']['license']['url']); + } + private function buildDeprecationArgs(string $version, string $message): array { return method_exists(Definition::class, 'getDeprecation') diff --git a/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php b/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php index b6a16ffe12e..d46115b8c64 100644 --- a/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php +++ b/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php @@ -201,6 +201,7 @@ public function getConfigTreeBuilder() $this->addMercureSection($rootNode); $this->addMessengerSection($rootNode); $this->addElasticsearchSection($rootNode); + $this->addOpenApiSection($rootNode); $this->addExceptionToStatusSection($rootNode); @@ -467,6 +468,34 @@ private function addElasticsearchSection(ArrayNodeDefinition $rootNode): void ->end(); } + private function addOpenApiSection(ArrayNodeDefinition $rootNode): void + { + $rootNode + ->children() + ->arrayNode('openapi') + ->addDefaultsIfNotSet() + ->children() + ->arrayNode('contact') + ->addDefaultsIfNotSet() + ->children() + ->scalarNode('name')->defaultNull()->info('The identifying name of the contact person/organization.')->end() + ->scalarNode('url')->defaultNull()->info('The URL pointing to the contact information. MUST be in the format of a URL.')->end() + ->scalarNode('email')->defaultNull()->info('The email address of the contact person/organization. MUST be in the format of an email address.')->end() + ->end() + ->end() + ->scalarNode('termsOfService')->defaultNull()->info('A URL to the Terms of Service for the API. MUST be in the format of a URL.')->end() + ->arrayNode('license') + ->addDefaultsIfNotSet() + ->children() + ->scalarNode('name')->defaultNull()->info('The license name used for the API.')->end() + ->scalarNode('url')->defaultNull()->info('URL to the license used for the API. MUST be in the format of a URL.')->end() + ->end() + ->end() + ->end() + ->end() + ->end(); + } + /** * @throws InvalidConfigurationException */ diff --git a/src/Bridge/Symfony/Bundle/Resources/config/openapi.xml b/src/Bridge/Symfony/Bundle/Resources/config/openapi.xml index 03bda2f24da..065dc4d33ad 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/openapi.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/openapi.xml @@ -23,6 +23,12 @@ %api_platform.oauth.refreshUrl% %api_platform.oauth.scopes% %api_platform.swagger.api_keys% + %api_platform.openapi.contact.name% + %api_platform.openapi.contact.url% + %api_platform.openapi.contact.email% + %api_platform.openapi.termsOfService% + %api_platform.openapi.license.name% + %api_platform.openapi.license.url% diff --git a/src/OpenApi/Factory/OpenApiFactory.php b/src/OpenApi/Factory/OpenApiFactory.php index 8b4fec96b55..208299f814c 100644 --- a/src/OpenApi/Factory/OpenApiFactory.php +++ b/src/OpenApi/Factory/OpenApiFactory.php @@ -77,7 +77,9 @@ public function __construct(ResourceNameCollectionFactoryInterface $resourceName public function __invoke(array $context = []): OpenApi { $baseUrl = $context[self::BASE_URL] ?? '/'; - $info = new Model\Info($this->openApiOptions->getTitle(), $this->openApiOptions->getVersion(), trim($this->openApiOptions->getDescription())); + $contact = null === $this->openApiOptions->getContactUrl() || null === $this->openApiOptions->getContactEmail() ? null : new Model\Contact($this->openApiOptions->getContactName(), $this->openApiOptions->getContactUrl(), $this->openApiOptions->getContactEmail()); + $license = null === $this->openApiOptions->getLicenseName() ? null : new Model\License($this->openApiOptions->getLicenseName(), $this->openApiOptions->getLicenseUrl()); + $info = new Model\Info($this->openApiOptions->getTitle(), $this->openApiOptions->getVersion(), trim($this->openApiOptions->getDescription()), $this->openApiOptions->getTermsOfService(), $contact, $license); $servers = '/' === $baseUrl || '' === $baseUrl ? [new Model\Server('/')] : [new Model\Server($baseUrl)]; $paths = new Model\Paths(); $links = []; diff --git a/src/OpenApi/Model/Contact.php b/src/OpenApi/Model/Contact.php index 713de0ff65e..61cc320ef99 100644 --- a/src/OpenApi/Model/Contact.php +++ b/src/OpenApi/Model/Contact.php @@ -21,29 +21,29 @@ final class Contact private $url; private $email; - public function __construct(string $name = '', string $url = '', string $email = '') + public function __construct(string $name = null, string $url = null, string $email = null) { $this->name = $name; $this->url = $url; $this->email = $email; } - public function getName(): string + public function getName(): ?string { return $this->name; } - public function getUrl(): string + public function getUrl(): ?string { return $this->url; } - public function getEmail(): string + public function getEmail(): ?string { return $this->email; } - public function withName(string $name): self + public function withName(?string $name): self { $clone = clone $this; $clone->name = $name; @@ -51,7 +51,7 @@ public function withName(string $name): self return $clone; } - public function withUrl(string $url): self + public function withUrl(?string $url): self { $clone = clone $this; $clone->url = $url; @@ -59,7 +59,7 @@ public function withUrl(string $url): self return $clone; } - public function withEmail(string $email): self + public function withEmail(?string $email): self { $clone = clone $this; $clone->email = $email; diff --git a/src/OpenApi/Model/License.php b/src/OpenApi/Model/License.php index ebc183c9aa0..11c80bb849b 100644 --- a/src/OpenApi/Model/License.php +++ b/src/OpenApi/Model/License.php @@ -20,7 +20,7 @@ final class License private $name; private $url; - public function __construct(string $name, string $url) + public function __construct(string $name, string $url = null) { $this->name = $name; $this->url = $url; @@ -31,7 +31,7 @@ public function getName(): string return $this->name; } - public function getUrl(): string + public function getUrl(): ?string { return $this->url; } @@ -44,7 +44,7 @@ public function withName(string $name): self return $clone; } - public function withUrl(string $url): self + public function withUrl(?string $url): self { $clone = clone $this; $clone->url = $url; diff --git a/src/OpenApi/Options.php b/src/OpenApi/Options.php index c1b35e2e719..043c16f1b47 100644 --- a/src/OpenApi/Options.php +++ b/src/OpenApi/Options.php @@ -26,8 +26,14 @@ final class Options private $oAuthRefreshUrl; private $oAuthScopes; private $apiKeys; - - public function __construct(string $title, string $description = '', string $version = '', bool $oAuthEnabled = false, string $oAuthType = '', string $oAuthFlow = '', string $oAuthTokenUrl = '', string $oAuthAuthorizationUrl = '', string $oAuthRefreshUrl = '', array $oAuthScopes = [], array $apiKeys = []) + private $contactName; + private $contactUrl; + private $contactEmail; + private $termsOfService; + private $licenseName; + private $licenseUrl; + + public function __construct(string $title, string $description = '', string $version = '', bool $oAuthEnabled = false, string $oAuthType = '', string $oAuthFlow = '', string $oAuthTokenUrl = '', string $oAuthAuthorizationUrl = '', string $oAuthRefreshUrl = '', array $oAuthScopes = [], array $apiKeys = [], string $contactName = null, string $contactUrl = null, string $contactEmail = null, string $termsOfService = null, string $licenseName = null, string $licenseUrl = null) { $this->title = $title; $this->description = $description; @@ -40,6 +46,12 @@ public function __construct(string $title, string $description = '', string $ver $this->oAuthRefreshUrl = $oAuthRefreshUrl; $this->oAuthScopes = $oAuthScopes; $this->apiKeys = $apiKeys; + $this->contactName = $contactName; + $this->contactUrl = $contactUrl; + $this->contactEmail = $contactEmail; + $this->termsOfService = $termsOfService; + $this->licenseName = $licenseName; + $this->licenseUrl = $licenseUrl; } public function getTitle(): string @@ -96,4 +108,34 @@ public function getApiKeys(): array { return $this->apiKeys; } + + public function getContactName(): ?string + { + return $this->contactName; + } + + public function getContactUrl(): ?string + { + return $this->contactUrl; + } + + public function getContactEmail(): ?string + { + return $this->contactEmail; + } + + public function getTermsOfService(): ?string + { + return $this->termsOfService; + } + + public function getLicenseName(): ?string + { + return $this->licenseName; + } + + public function getLicenseUrl(): ?string + { + return $this->licenseUrl; + } } diff --git a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php index af349507afa..4bcfeebb310 100644 --- a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php +++ b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php @@ -1142,6 +1142,12 @@ private function getBaseContainerBuilderProphecy(array $doctrineIntegrationsToLo 'api_platform.elasticsearch.enabled' => false, 'api_platform.asset_package' => null, 'api_platform.defaults' => ['attributes' => ['stateless' => true]], + 'api_platform.openapi.termsOfService' => null, + 'api_platform.openapi.contact.name' => null, + 'api_platform.openapi.contact.url' => null, + 'api_platform.openapi.contact.email' => null, + 'api_platform.openapi.license.name' => null, + 'api_platform.openapi.license.url' => null, ]; if ($hasSwagger) { diff --git a/tests/Bridge/Symfony/Bundle/DependencyInjection/ConfigurationTest.php b/tests/Bridge/Symfony/Bundle/DependencyInjection/ConfigurationTest.php index b8ef9419838..d00b136fee8 100644 --- a/tests/Bridge/Symfony/Bundle/DependencyInjection/ConfigurationTest.php +++ b/tests/Bridge/Symfony/Bundle/DependencyInjection/ConfigurationTest.php @@ -207,6 +207,18 @@ private function runDefaultConfigTests(array $doctrineIntegrationsToLoad = ['orm 'allow_plain_identifiers' => false, 'resource_class_directories' => [], 'asset_package' => null, + 'openapi' => [ + 'contact' => [ + 'name' => null, + 'url' => null, + 'email' => null, + ], + 'termsOfService' => null, + 'license' => [ + 'name' => null, + 'url' => null, + ], + ], ], $config); } From a0093405e5b3172030d48cf4b6d072bc1af58c2c Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Wed, 25 Nov 2020 09:54:36 +0100 Subject: [PATCH 148/160] Apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Kévin Dunglas --- docs/adr/0000-subresources-definition.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/adr/0000-subresources-definition.md b/docs/adr/0000-subresources-definition.md index 0045548b601..72cccb17e03 100644 --- a/docs/adr/0000-subresources-definition.md +++ b/docs/adr/0000-subresources-definition.md @@ -1,4 +1,4 @@ -# Subresource definition +# Subresource Definition * Status: proposed * Deciders: @dunglas, @vincentchalamon, @soyuka, @GregoireHebert, @Deuchnord From c31c9d94a4852f57a9eb3bbf08927378aa84876b Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 26 Nov 2020 09:17:39 +0100 Subject: [PATCH 149/160] Fix #3844 open api shows with jsonld normalizer --- src/Documentation/Action/DocumentationAction.php | 2 +- tests/Documentation/Action/DocumentationActionTest.php | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Documentation/Action/DocumentationAction.php b/src/Documentation/Action/DocumentationAction.php index 754fc013b4f..29dab584ae6 100644 --- a/src/Documentation/Action/DocumentationAction.php +++ b/src/Documentation/Action/DocumentationAction.php @@ -85,7 +85,7 @@ public function __invoke(Request $request = null): DocumentationInterface $this->formats = $this->formatsProvider->getFormatsFromAttributes($attributes ?? []); } - if (null !== $this->openApiFactory && isset($context) && 3 === $context['spec_version']) { + if ('json' === $request->getRequestFormat() && null !== $this->openApiFactory && 3 === ($context['spec_version'] ?? null)) { return $this->openApiFactory->__invoke($context ?? []); } diff --git a/tests/Documentation/Action/DocumentationActionTest.php b/tests/Documentation/Action/DocumentationActionTest.php index 3016d39c744..4d6556d49ba 100644 --- a/tests/Documentation/Action/DocumentationActionTest.php +++ b/tests/Documentation/Action/DocumentationActionTest.php @@ -43,6 +43,7 @@ class DocumentationActionTest extends TestCase public function testDocumentationAction(): void { $requestProphecy = $this->prophesize(Request::class); + $requestProphecy->getRequestFormat()->willReturn('json'); $attributesProphecy = $this->prophesize(ParameterBagInterface::class); $queryProphecy = $this->prophesize(ParameterBag::class); $resourceNameCollectionFactoryProphecy = $this->prophesize(ResourceNameCollectionFactoryInterface::class); @@ -63,6 +64,7 @@ public function testDocumentationAction(): void public function testLegacyDocumentationAction(): void { $requestProphecy = $this->prophesize(Request::class); + $requestProphecy->getRequestFormat()->willReturn('json'); $attributesProphecy = $this->prophesize(ParameterBagInterface::class); $queryProphecy = $this->prophesize(ParameterBag::class); $resourceNameCollectionFactoryProphecy = $this->prophesize(ResourceNameCollectionFactoryInterface::class); @@ -97,6 +99,7 @@ public function testDocumentationActionV2(): void { $openApiFactoryProphecy = $this->prophesize(OpenApiFactoryInterface::class); $requestProphecy = $this->prophesize(Request::class); + $requestProphecy->getRequestFormat()->willReturn('json'); $attributesProphecy = $this->prophesize(ParameterBagInterface::class); $queryProphecy = $this->prophesize(ParameterBag::class); $resourceNameCollectionFactoryProphecy = $this->prophesize(ResourceNameCollectionFactoryInterface::class); @@ -120,6 +123,7 @@ public function testDocumentationActionV3(): void $openApiFactoryProphecy = $this->prophesize(OpenApiFactoryInterface::class); $openApiFactoryProphecy->__invoke(Argument::any())->shouldBeCalled()->willReturn($openApi); $requestProphecy = $this->prophesize(Request::class); + $requestProphecy->getRequestFormat()->willReturn('json'); $attributesProphecy = $this->prophesize(ParameterBagInterface::class); $queryProphecy = $this->prophesize(ParameterBag::class); $resourceNameCollectionFactoryProphecy = $this->prophesize(ResourceNameCollectionFactoryInterface::class); From 01ed8dd04ecb301ab410dd83b1ba0f0ea66b1462 Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 19 Nov 2020 17:23:20 +0100 Subject: [PATCH 150/160] Resource identifiers --- docs/adr/0001-resource-identifiers.md | 155 ++++++++++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 docs/adr/0001-resource-identifiers.md diff --git a/docs/adr/0001-resource-identifiers.md b/docs/adr/0001-resource-identifiers.md new file mode 100644 index 00000000000..64abeb030db --- /dev/null +++ b/docs/adr/0001-resource-identifiers.md @@ -0,0 +1,155 @@ +# Resource identifiers + +* Status: proposed +* Deciders: @dunglas @alanpoulain @soyuka + +Technical Story: [#2126][pull/2126] +Implementation: [#3825][pull/3825 ] + +## Context and Problem Statement + +In API Platform, a resource is identified by [IRIs][rfc/IRI], for example `/books/1`. Internally, this is also known as a route with an identifier parameter named `id`: `/books/{id}`. This `id` parameter is then matched to the resource identifiers, known by the `ApiProperty` metadata when `identifier` is true. When multiple identifiers are found, composite identifiers map the value of `id` to the resource identifiers (eg: `keya=value1;keyb=value2`, where `keya` and `keyb` are identifiers of the resource). This behavior is suggested by the [URI RFC][rfc/URI]. +Subresources IRIs have multiple parts, for example: `/books/{id}/author/{authorId}`. The router needs to know that `id` matches the `Book` resource, and `authorId` the `Author` resource. To do so, a Tuple representing the class and the property matching each parameter is linked to the route, for example: `id: [Book, id], authorId: [User, id]`. +By normalizing the shape of (sub)-resources (see [0000-subresources-definition][0000-subresources-definition]), we need to normalize the resource identifiers. + +## Decision Outcome + +Declare explicit resource `identifiers` that will default to `id: [id, Resource]` with composite identifiers. Allow composite identifiers to be disabled if needed. + +### Examples + +Define a route `/users/{id}` + +```php +/** + * @ApiResource + */ + class User { + /** @ApiProperty(identifier=true) */ + public int $id; + } +``` + +Or + +```php +/** + * @ApiResource(identifiers={"id": {User::class, "id"}}) + */ + class User { + /** @ApiProperty(identifier=true) */ + public int $id; + } +``` + +Define a route `/users/{username}` that uses the username identifier: + +```php +/** + * @ApiResource(identifiers={"username"}) + */ + class User { + /** @ApiProperty(identifier=true) */ + public string $username; + } +``` + +Or + +```php +/** + * @ApiResource(identifiers={"username": {User::class, "username"}}) + */ + class User { + /** @ApiProperty(identifier=true) */ + public string $username; + } +``` + +Define a route `/users/{username}` that uses the property shortName: + +```php +/** + * @ApiResource(identifiers={"username"={User::class, "shortName"}}) + */ + class User { + /** @ApiProperty(identifier=true) */ + public string $shortName; + } +``` + +Define a route `/users/{composite}` that uses composite identifiers `/users/keya=value1;keyb=value2`: + +```php +/** + * @ApiResource(identifiers={"composite"}) + */ + class User { + /** @ApiProperty(identifier=true) */ + public string $keya; + /** @ApiProperty(identifier=true) */ + public string $keyb; + } +``` + +Define a route `/users/{keya}/{keyb}`: + +```php +/** + * @ApiResource(identifiers={"keya", "keyb"}, compositeIdentifier=false) + */ + class User { + /** @ApiProperty(identifier=true) */ + public string $keya; + /** @ApiProperty(identifier=true) */ + public string $keyb; + } +``` + +Complex version: + +```php +/** + * @ApiResource(identifiers={"keya"={User::class, "keya"}, "keyb"={User::class, "keyb"}}, compositeIdentifier=false) + */ + class User { + /** @ApiProperty(identifier=true) */ + public string $keya; + /** @ApiProperty(identifier=true) */ + public string $keyb; + } +``` + +Define a subresource `/companies/{companyId}/users/{id}`: + +```php +/** + * @ApiResource(path="/users", identifiers={"id": {User::class, "id"}}) + * @ApiResource(path="/companies/{companyId}/users", identifiers={"companyId": {Company::class, "id"}, "id": {User::class, "id"}}) + */ +class User { + /** @ApiProperty(identifier=true) */ + public int $id; + + /** @var Company[] */ + public array $companies = []; +} + +class Company { + /** @ApiProperty(identifier=true) */ + public int $id; + + /** @var User[] */ + public $users; +} +``` + +## Links + +* Adds up to the [0000-subresources-definition][0000-subresources-definition] rework. + +[0000-subresources-definition]: ./0000-subresources-definition "Subresources definition" +[pull/2126]: https://github.com/api-platform/core/pull/2126 "Ability to specify identifier property of custom item operations" +[pull/3825]: https://github.com/api-platform/core/pull/3825 "Rework to improve and simplify identifiers management" +[rfc/IRI]: https://tools.ietf.org/html/rfc3987 "RFC3987" +[rfc/URI]: https://tools.ietf.org/html/rfc3986#section-3.3 "RFC 3986" From 001f71494f2fe6d77064cd467182d573a2eeb9f5 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Thu, 26 Nov 2020 13:08:48 +0100 Subject: [PATCH 151/160] Apply suggestions from code review Co-authored-by: Alan Poulain --- docs/adr/0001-resource-identifiers.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/adr/0001-resource-identifiers.md b/docs/adr/0001-resource-identifiers.md index 64abeb030db..4e5ac69b6c8 100644 --- a/docs/adr/0001-resource-identifiers.md +++ b/docs/adr/0001-resource-identifiers.md @@ -1,16 +1,16 @@ -# Resource identifiers +# Resource Identifiers * Status: proposed * Deciders: @dunglas @alanpoulain @soyuka Technical Story: [#2126][pull/2126] -Implementation: [#3825][pull/3825 ] +Implementation: [#3825][pull/3825] ## Context and Problem Statement In API Platform, a resource is identified by [IRIs][rfc/IRI], for example `/books/1`. Internally, this is also known as a route with an identifier parameter named `id`: `/books/{id}`. This `id` parameter is then matched to the resource identifiers, known by the `ApiProperty` metadata when `identifier` is true. When multiple identifiers are found, composite identifiers map the value of `id` to the resource identifiers (eg: `keya=value1;keyb=value2`, where `keya` and `keyb` are identifiers of the resource). This behavior is suggested by the [URI RFC][rfc/URI]. Subresources IRIs have multiple parts, for example: `/books/{id}/author/{authorId}`. The router needs to know that `id` matches the `Book` resource, and `authorId` the `Author` resource. To do so, a Tuple representing the class and the property matching each parameter is linked to the route, for example: `id: [Book, id], authorId: [User, id]`. -By normalizing the shape of (sub)-resources (see [0000-subresources-definition][0000-subresources-definition]), we need to normalize the resource identifiers. +By normalizing the shape of (sub-)resources (see [0000-subresources-definition][0000-subresources-definition]), we need to normalize the resource identifiers. ## Decision Outcome @@ -18,7 +18,7 @@ Declare explicit resource `identifiers` that will default to `id: [id, Resource] ### Examples -Define a route `/users/{id}` +Define a route `/users/{id}`: ```php /** From 3f4bd56cfbf01aa9bb8cad1bc18d69b843184534 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Dunglas?= Date: Fri, 27 Nov 2020 00:14:54 +0100 Subject: [PATCH 152/160] feat: add ApiResource PHP 8 attribute --- .gitignore | 1 + src/Annotation/ApiResource.php | 118 +++++++++++++++++- .../AnnotationResourceMetadataFactory.php | 10 +- tests/Annotation/ApiResourceTest.php | 109 ++++++++++++++++ .../Fixtures/TestBundle/Entity/DummyPhp8.php | 26 ++++ .../AnnotationResourceMetadataFactoryTest.php | 12 ++ 6 files changed, 270 insertions(+), 6 deletions(-) create mode 100644 tests/Fixtures/TestBundle/Entity/DummyPhp8.php diff --git a/.gitignore b/.gitignore index 8c62c7fc8fa..1d12eccbda9 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ /tests/Fixtures/app/var/ /tests/Fixtures/app/public/bundles/ /vendor/ +/Dockerfile diff --git a/src/Annotation/ApiResource.php b/src/Annotation/ApiResource.php index 831fd76f0cd..ef323528bf2 100644 --- a/src/Annotation/ApiResource.php +++ b/src/Annotation/ApiResource.php @@ -71,18 +71,28 @@ * @Attribute("validationGroups", type="mixed"), * ) */ +#[\Attribute(\Attribute::TARGET_CLASS)] final class ApiResource { use AttributesHydratorTrait; + private const PUBLIC_PROPERTIES = [ + 'description', + 'collectionOperations', + 'graphql', + 'iri', + 'itemOperations', + 'shortName', + 'subresourceOperations', + ]; + /** * @internal * * @see \ApiPlatform\Core\Bridge\Symfony\Bundle\DependencyInjection\Configuration::addDefaultsSection */ public const CONFIGURABLE_DEFAULTS = [ - 'accessControl', - 'accessControlMessage', + 'attributes', 'security', 'securityMessage', 'securityPostDenormalize', @@ -114,7 +124,6 @@ final class ApiResource 'paginationEnabled', 'paginationFetchJoinCollection', 'paginationItemsPerPage', - 'maximumItemsPerPage', 'paginationMaximumItemsPerPage', 'paginationPartial', 'paginationViaCursor', @@ -453,10 +462,109 @@ final class ApiResource private $urlGenerationStrategy; /** + * @param array|string $valuesOrDescription + * @param array $collectionOperations https://api-platform.com/docs/core/operations + * @param array $graphql https://api-platform.com/docs/core/graphql + * @param array $itemOperations https://api-platform.com/docs/core/operations + * @param array $subresourceOperations https://api-platform.com/docs/core/subresources + * + * @param array $cacheHeaders https://api-platform.com/docs/core/performance/#setting-custom-http-cache-headers + * @param array $denormalizationContext https://api-platform.com/docs/core/serialization/#using-serialization-groups + * @param string $deprecationReason https://api-platform.com/docs/core/deprecations/#deprecating-resource-classes-operations-and-properties + * @param bool $elasticsearch https://api-platform.com/docs/core/elasticsearch/ + * @param bool $fetchPartial https://api-platform.com/docs/core/performance/#fetch-partial + * @param bool $forceEager https://api-platform.com/docs/core/performance/#force-eager + * @param array $formats https://api-platform.com/docs/core/content-negotiation/#configuring-formats-for-a-specific-resource-or-operation + * @param string[] $filters https://api-platform.com/docs/core/filters/#doctrine-orm-and-mongodb-odm-filters + * @param string[] $hydraContext https://api-platform.com/docs/core/extending-jsonld-context/#hydra + * @param string|false $input https://api-platform.com/docs/core/dto/#specifying-an-input-or-an-output-data-representation + * @param bool|array $mercure https://api-platform.com/docs/core/mercure + * @param bool $messenger https://api-platform.com/docs/core/messenger/#dispatching-a-resource-through-the-message-bus + * @param array $normalizationContext https://api-platform.com/docs/core/serialization/#using-serialization-groups + * @param array $openapiContext https://api-platform.com/docs/core/swagger/#using-the-openapi-and-swagger-contexts + * @param array $order https://api-platform.com/docs/core/default-order/#overriding-default-order + * @param string|false $output https://api-platform.com/docs/core/dto/#specifying-an-input-or-an-output-data-representation + * @param bool $paginationClientEnabled https://api-platform.com/docs/core/pagination/#for-a-specific-resource-1 + * @param bool $paginationClientItemsPerPage https://api-platform.com/docs/core/pagination/#for-a-specific-resource-3 + * @param bool $paginationClientPartial https://api-platform.com/docs/core/pagination/#for-a-specific-resource-6 + * @param array $paginationViaCursor https://api-platform.com/docs/core/pagination/#cursor-based-pagination + * @param bool $paginationEnabled https://api-platform.com/docs/core/pagination/#for-a-specific-resource + * @param bool $paginationFetchJoinCollection https://api-platform.com/docs/core/pagination/#controlling-the-behavior-of-the-doctrine-orm-paginator + * @param int $paginationItemsPerPage https://api-platform.com/docs/core/pagination/#changing-the-number-of-items-per-page + * @param int $paginationMaximumItemsPerPage https://api-platform.com/docs/core/pagination/#changing-maximum-items-per-page + * @param bool $paginationPartial https://api-platform.com/docs/core/performance/#partial-pagination + * @param string $routePrefix https://api-platform.com/docs/core/operations/#prefixing-all-routes-of-all-operations + * @param string $security https://api-platform.com/docs/core/security + * @param string $securityMessage https://api-platform.com/docs/core/security/#configuring-the-access-control-error-message + * @param string $securityPostDenormalize https://api-platform.com/docs/core/security/#executing-access-control-rules-after-denormalization + * @param string $securityPostDenormalizeMessage https://api-platform.com/docs/core/security/#configuring-the-access-control-error-message + * @param bool $stateless + * @param string $sunset https://api-platform.com/docs/core/deprecations/#setting-the-sunset-http-header-to-indicate-when-a-resource-or-an-operation-will-be-removed + * @param array $swaggerContext https://api-platform.com/docs/core/swagger/#using-the-openapi-and-swagger-contexts + * @param array $validationGroups https://api-platform.com/docs/core/validation/#using-validation-groups + * @param int $urlGenerationStrategy + * * @throws InvalidArgumentException */ - public function __construct(array $values = []) + public function __construct( + $description = null, + array $collectionOperations = [], + array $graphql = [], + string $iri = '', + array $itemOperations = [], + string $shortName = '', + array $subresourceOperations = [], + + // attributes + ?array $attributes = null, + ?array $cacheHeaders = null, + ?array $denormalizationContext = null, + ?string $deprecationReason = null, + ?bool $elasticsearch = null, + ?bool $fetchPartial = null, + ?bool $forceEager = null, + ?array $formats = null, + ?array $filters = null, + ?array $hydraContext = null, + $input = null, + $mercure = null, + $messenger = null, + ?array $normalizationContext = null, + ?array $openapiContext = null, + ?array $order = null, + $output = null, + ?bool $paginationClientEnabled = null, + ?bool $paginationClientItemsPerPage = null, + ?bool $paginationClientPartial = null, + ?array $paginationViaCursor = null, + ?bool $paginationEnabled = null, + ?bool $paginationFetchJoinCollection = null, + ?int $paginationItemsPerPage = null, + ?int $paginationMaximumItemsPerPage = null, + ?bool $paginationPartial = null, + ?string $routePrefix = null, + ?string $security = null, + ?string $securityMessage = null, + ?string $securityPostDenormalize = null, + ?string $securityPostDenormalizeMessage = null, + ?bool $stateless = null, + ?string $sunset = null, + ?array $swaggerContext = null, + ?array $validationGroups = null, + ?int $urlGenerationStrategy = null +) { - $this->hydrateAttributes($values); + if (!is_array($description)) { + foreach (self::PUBLIC_PROPERTIES as $prop) { + $this->$prop = $$prop; + } + + $description = []; + foreach (array_diff(self::CONFIGURABLE_DEFAULTS, self::PUBLIC_PROPERTIES) as $attribute) { + $description[$attribute] = $$attribute; + } + } + + $this->hydrateAttributes($description); } } diff --git a/src/Metadata/Resource/Factory/AnnotationResourceMetadataFactory.php b/src/Metadata/Resource/Factory/AnnotationResourceMetadataFactory.php index e55a024519d..cc9bc054499 100644 --- a/src/Metadata/Resource/Factory/AnnotationResourceMetadataFactory.php +++ b/src/Metadata/Resource/Factory/AnnotationResourceMetadataFactory.php @@ -29,7 +29,7 @@ final class AnnotationResourceMetadataFactory implements ResourceMetadataFactory private $decorated; private $defaults; - public function __construct(Reader $reader, ResourceMetadataFactoryInterface $decorated = null, array $defaults = []) + public function __construct(Reader $reader = null, ResourceMetadataFactoryInterface $decorated = null, array $defaults = []) { $this->reader = $reader; $this->decorated = $decorated; @@ -56,6 +56,14 @@ public function create(string $resourceClass): ResourceMetadata return $this->handleNotFound($parentResourceMetadata, $resourceClass); } + if (\PHP_VERSION_ID >= 80000 && $attributes = $reflectionClass->getAttributes(ApiResource::class)) { + return $this->createMetadata($attributes[0]->newInstance(), $parentResourceMetadata); + } + + if (null === $this->reader) { + $this->handleNotFound($parentResourceMetadata, $resourceClass); + } + $resourceAnnotation = $this->reader->getClassAnnotation($reflectionClass, ApiResource::class); if (!$resourceAnnotation instanceof ApiResource) { return $this->handleNotFound($parentResourceMetadata, $resourceClass); diff --git a/tests/Annotation/ApiResourceTest.php b/tests/Annotation/ApiResourceTest.php index 48e040c0ac9..5c8340549f5 100644 --- a/tests/Annotation/ApiResourceTest.php +++ b/tests/Annotation/ApiResourceTest.php @@ -17,6 +17,7 @@ use ApiPlatform\Core\Api\UrlGeneratorInterface; use ApiPlatform\Core\Exception\InvalidArgumentException; use ApiPlatform\Core\Tests\Fixtures\AnnotatedClass; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyPhp8; use Doctrine\Common\Annotations\AnnotationReader; use PHPUnit\Framework\TestCase; @@ -111,6 +112,114 @@ public function testConstruct() ], $resource->attributes); } + /** + * @requires PHP 8.0 + */ + public function testConstructAttribute() + { + $resource = eval(<<<'PHP' +return new \ApiPlatform\Core\Annotation\ApiResource( + security: 'is_granted("ROLE_FOO")', + securityMessage: 'You are not foo.', + securityPostDenormalize: 'is_granted("ROLE_BAR")', + securityPostDenormalizeMessage: 'You are not bar.', + attributes: ['foo' => 'bar', 'validation_groups' => ['baz', 'qux'], 'cache_headers' => ['max_age' => 0, 'shared_max_age' => 0, 'vary' => ['Custom-Vary-1', 'Custom-Vary-2']]], + collectionOperations: ['bar' => ['foo']], + denormalizationContext: ['groups' => ['foo']], + description: 'description', + fetchPartial: true, + forceEager: false, + formats: ['foo', 'bar' => ['application/bar']], + filters: ['foo', 'bar'], + graphql: ['query' => ['normalization_context' => ['groups' => ['foo', 'bar']]]], + input: 'Foo', + iri: 'http://example.com/res', + itemOperations: ['foo' => ['bar']], + mercure: ['private' => true], + messenger: true, + normalizationContext: ['groups' => ['bar']], + order: ['foo', 'bar' => 'ASC'], + openapiContext: ['description' => 'foo'], + output: 'Bar', + paginationClientEnabled: true, + paginationClientItemsPerPage: true, + paginationClientPartial: true, + paginationEnabled: true, + paginationFetchJoinCollection: true, + paginationItemsPerPage: 42, + paginationMaximumItemsPerPage: 50, + paginationPartial: true, + routePrefix: '/foo', + shortName: 'shortName', + subresourceOperations: [], + swaggerContext: ['description' => 'bar'], + validationGroups: ['foo', 'bar'], + sunset: 'Thu, 11 Oct 2018 00:00:00 +0200', + urlGenerationStrategy: \ApiPlatform\Core\Api\UrlGeneratorInterface::ABS_PATH, + deprecationReason: 'reason', + elasticsearch: true, + hydraContext: ['hydra' => 'foo'], + paginationViaCursor: ['foo'], + stateless: true, +); +PHP + ); + + $this->assertSame('shortName', $resource->shortName); + $this->assertSame('description', $resource->description); + $this->assertSame('http://example.com/res', $resource->iri); + $this->assertSame(['foo' => ['bar']], $resource->itemOperations); + $this->assertSame(['bar' => ['foo']], $resource->collectionOperations); + $this->assertSame([], $resource->subresourceOperations); + $this->assertSame(['query' => ['normalization_context' => ['groups' => ['foo', 'bar']]]], $resource->graphql); + $this->assertEquals([ + 'security' => 'is_granted("ROLE_FOO")', + 'security_message' => 'You are not foo.', + 'security_post_denormalize' => 'is_granted("ROLE_BAR")', + 'security_post_denormalize_message' => 'You are not bar.', + 'denormalization_context' => ['groups' => ['foo']], + 'fetch_partial' => true, + 'foo' => 'bar', + 'force_eager' => false, + 'formats' => ['foo', 'bar' => ['application/bar']], + 'filters' => ['foo', 'bar'], + 'input' => 'Foo', + 'mercure' => ['private' => true], + 'messenger' => true, + 'normalization_context' => ['groups' => ['bar']], + 'order' => ['foo', 'bar' => 'ASC'], + 'openapi_context' => ['description' => 'foo'], + 'output' => 'Bar', + 'pagination_client_enabled' => true, + 'pagination_client_items_per_page' => true, + 'pagination_client_partial' => true, + 'pagination_enabled' => true, + 'pagination_fetch_join_collection' => true, + 'pagination_items_per_page' => 42, + 'pagination_maximum_items_per_page' => 50, + 'pagination_partial' => true, + 'route_prefix' => '/foo', + 'swagger_context' => ['description' => 'bar'], + 'validation_groups' => ['baz', 'qux'], + 'cache_headers' => ['max_age' => 0, 'shared_max_age' => 0, 'vary' => ['Custom-Vary-1', 'Custom-Vary-2']], + 'sunset' => 'Thu, 11 Oct 2018 00:00:00 +0200', + 'url_generation_strategy' => 1, + 'deprecation_reason' => 'reason', + 'elasticsearch' => true, + 'hydra_context' => ['hydra' => 'foo'], + 'pagination_via_cursor' => ['foo'], + 'stateless' => true, + ], $resource->attributes); + } + + /** + * @requires PHP 8.0 + */ + public function testUseAttribute() + { + $this->assertSame('Hey PHP 8', (new \ReflectionClass(DummyPhp8::class))->getAttributes(ApiResource::class)[0]->getArguments()['description']); + } + public function testApiResourceAnnotation() { $reader = new AnnotationReader(); diff --git a/tests/Fixtures/TestBundle/Entity/DummyPhp8.php b/tests/Fixtures/TestBundle/Entity/DummyPhp8.php new file mode 100644 index 00000000000..563c37af068 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/DummyPhp8.php @@ -0,0 +1,26 @@ + + * + * 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\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Core\Annotation\ApiProperty; +use ApiPlatform\Core\Annotation\ApiResource; + +#[ApiResource(description: "Hey PHP 8")] +class DummyPhp8 +{ + /** + * @ApiProperty(identifier=true) + */ + public $id; +} diff --git a/tests/Metadata/Resource/Factory/AnnotationResourceMetadataFactoryTest.php b/tests/Metadata/Resource/Factory/AnnotationResourceMetadataFactoryTest.php index 6e974afc1bd..caeb6fb60c6 100644 --- a/tests/Metadata/Resource/Factory/AnnotationResourceMetadataFactoryTest.php +++ b/tests/Metadata/Resource/Factory/AnnotationResourceMetadataFactoryTest.php @@ -19,6 +19,7 @@ use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyPhp8; use ApiPlatform\Core\Tests\ProphecyTrait; use Doctrine\Common\Annotations\Reader; use PHPUnit\Framework\TestCase; @@ -49,6 +50,17 @@ public function testCreate($reader, $decorated, string $expectedShortName, ?stri $this->assertEquals(['foo' => 'bar'], $metadata->getGraphql()); } + /** + * @requires PHP 8.0 + */ + public function testCreateAttribute() + { + $factory = new AnnotationResourceMetadataFactory(); + $metadata = $factory->create(DummyPhp8::class); + + $this->assertSame('Hey PHP 8', $metadata->getDescription()); + } + public function testCreateWithDefaults() { $defaults = [ From 11dafa507e2d43f87c6d83b1ea1eeddc8ef7a184 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Dunglas?= Date: Fri, 27 Nov 2020 09:34:04 +0100 Subject: [PATCH 153/160] feat: add ApiProperty PHP 8 attribute --- src/Annotation/ApiProperty.php | 118 +++--- src/Annotation/ApiResource.php | 358 +----------------- src/Annotation/AttributesHydratorTrait.php | 48 ++- .../ApiPlatformExtension.php | 21 +- .../DependencyInjection/Configuration.php | 2 +- .../AnnotationPropertyMetadataFactory.php | 17 +- ...nnotationPropertyNameCollectionFactory.php | 15 +- ...nnotationResourceNameCollectionFactory.php | 7 +- tests/Annotation/ApiPropertyTest.php | 33 ++ tests/Annotation/ApiResourceTest.php | 2 - .../Fixtures/TestBundle/Entity/DummyPhp8.php | 10 +- .../AnnotationPropertyMetadataFactoryTest.php | 16 + ...ationPropertyNameCollectionFactoryTest.php | 12 + ...ationResourceNameCollectionFactoryTest.php | 12 + 14 files changed, 233 insertions(+), 438 deletions(-) diff --git a/src/Annotation/ApiProperty.php b/src/Annotation/ApiProperty.php index 7fcc96cabe0..cd2ae8ab31d 100644 --- a/src/Annotation/ApiProperty.php +++ b/src/Annotation/ApiProperty.php @@ -33,10 +33,16 @@ * @Attribute("swaggerContext", type="array") * ) */ +#[\Attribute(\Attribute::TARGET_PROPERTY|\Attribute::TARGET_METHOD)] final class ApiProperty { use AttributesHydratorTrait; + /** + * @var array + */ + private static $deprecatedAttributes = []; + /** * @var string */ @@ -88,66 +94,64 @@ final class ApiProperty public $example; /** - * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 - * - * @var string - */ - private $deprecationReason; - - /** - * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 - * - * @var bool - */ - private $fetchable; - - /** - * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 - * - * @var bool - */ - private $fetchEager; - - /** - * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 - * - * @var array - */ - private $jsonldContext; - - /** - * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 + * @param string $description + * @param bool $readable + * @param bool $writable + * @param bool $readableLink + * @param bool $writableLink + * @param bool $required + * @param bool $iri + * @param bool $identifier + * @param string|int|float|bool|array $default + * @param string|int|float|bool|array|null $example * - * @var array - */ - private $openapiContext; - - /** - * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 + * @param string $deprecationReason + * @param bool $fetchable + * @param bool $fetchEager + * @param array $jsonldContext + * @param array $openapiContext + * @param bool $push + * @param string $security + * @param array $swaggerContext * - * @var bool - */ - private $push; - - /** - * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 - * - * @var string - */ - private $security; - - /** - * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 - * - * @var array - */ - private $swaggerContext; - - /** * @throws InvalidArgumentException */ - public function __construct(array $values = []) - { - $this->hydrateAttributes($values); + public function __construct( + $description = null, + ?bool $readable = null, + ?bool $writable = null, + ?bool $readableLink = null, + ?bool $writableLink = null, + ?bool $required = null, + ?string $iri = null, + ?bool $identifier = null, + $default = null, + $example = null, + + // attributes + ?array $attributes = null, + ?string $deprecationReason = null, + ?bool $fetchable = null, + ?bool $fetchEager = null, + ?array $jsonldContext = null, + ?array $openapiContext = null, + ?bool $push = null, + ?string $security = null, + ?array $swaggerContext = null + ) { + if (!is_array($description)) { + [$publicProperties, $configurableAttributes] = self::getConfigMetadata(); + + foreach ($publicProperties as $prop => $_) { + $this->{$prop} = $$prop; + } + + $description = []; + foreach ($configurableAttributes as $attribute => $_) { + $description[$attribute] = $$attribute; + } + } + + $this->hydrateAttributes($description); } } diff --git a/src/Annotation/ApiResource.php b/src/Annotation/ApiResource.php index ef323528bf2..72a86338082 100644 --- a/src/Annotation/ApiResource.php +++ b/src/Annotation/ApiResource.php @@ -76,63 +76,13 @@ final class ApiResource { use AttributesHydratorTrait; - private const PUBLIC_PROPERTIES = [ - 'description', - 'collectionOperations', - 'graphql', - 'iri', - 'itemOperations', - 'shortName', - 'subresourceOperations', - ]; - /** - * @internal - * - * @see \ApiPlatform\Core\Bridge\Symfony\Bundle\DependencyInjection\Configuration::addDefaultsSection + * @var array */ - public const CONFIGURABLE_DEFAULTS = [ - 'attributes', - 'security', - 'securityMessage', - 'securityPostDenormalize', - 'securityPostDenormalizeMessage', - 'cacheHeaders', - 'collectionOperations', - 'denormalizationContext', - 'deprecationReason', - 'description', - 'elasticsearch', - 'fetchPartial', - 'forceEager', - 'formats', - 'filters', - 'graphql', - 'hydraContext', - 'input', - 'iri', - 'itemOperations', - 'mercure', - 'messenger', - 'normalizationContext', - 'openapiContext', - 'order', - 'output', - 'paginationClientEnabled', - 'paginationClientItemsPerPage', - 'paginationClientPartial', - 'paginationEnabled', - 'paginationFetchJoinCollection', - 'paginationItemsPerPage', - 'paginationMaximumItemsPerPage', - 'paginationPartial', - 'paginationViaCursor', - 'routePrefix', - 'stateless', - 'sunset', - 'swaggerContext', - 'urlGenerationStrategy', - 'validationGroups', + private static $deprecatedAttributes = [ + 'accessControl' => ['security', '2.5'], + 'accessControlMessage' => ['securityMessage', '2.5'], + 'maximumItemsPerPage' => ['paginationMaximumItemsPerPage', '2.6'], ]; /** @@ -179,290 +129,7 @@ final class ApiResource public $subresourceOperations; /** - * @see https://api-platform.com/docs/core/performance/#setting-custom-http-cache-headers - * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 - * - * @var array - */ - private $cacheHeaders; - - /** - * @see https://api-platform.com/docs/core/serialization/#using-serialization-groups - * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 - * - * @var array - */ - private $denormalizationContext; - - /** - * @see https://api-platform.com/docs/core/deprecations/#deprecating-resource-classes-operations-and-properties - * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 - * - * @var string - */ - private $deprecationReason; - - /** - * @see https://api-platform.com/docs/core/elasticsearch/ - * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 - * - * @var bool - */ - private $elasticsearch; - - /** - * @see https://api-platform.com/docs/core/performance/#fetch-partial - * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 - * - * @var bool - */ - private $fetchPartial; - - /** - * @see https://api-platform.com/docs/core/performance/#force-eager - * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 - * - * @var bool - */ - private $forceEager; - - /** - * @see https://api-platform.com/docs/core/content-negotiation/#configuring-formats-for-a-specific-resource-or-operation - * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 - * - * @var array - */ - private $formats; - - /** - * @see https://api-platform.com/docs/core/filters/#doctrine-orm-and-mongodb-odm-filters - * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 - * - * @var string[] - */ - private $filters; - - /** - * @see https://api-platform.com/docs/core/extending-jsonld-context/#hydra - * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 - * - * @var string[] - */ - private $hydraContext; - - /** - * @see https://api-platform.com/docs/core/dto/#specifying-an-input-or-an-output-data-representation - * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 - * - * @var string|false - */ - private $input; - - /** - * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 - * - * @var int - * - * @deprecated - Use $paginationMaximumItemsPerPage instead - */ - private $maximumItemsPerPage; - - /** - * @see https://api-platform.com/docs/core/mercure - * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 - */ - private $mercure; - - /** - * @see https://api-platform.com/docs/core/messenger/#dispatching-a-resource-through-the-message-bus - * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 - * - * @var bool|string - */ - private $messenger; - - /** - * @see https://api-platform.com/docs/core/serialization/#using-serialization-groups - * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 - * - * @var array - */ - private $normalizationContext; - - /** - * @see https://api-platform.com/docs/core/swagger/#using-the-openapi-and-swagger-contexts - * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 - * - * @var array - */ - private $openapiContext; - - /** - * @see https://api-platform.com/docs/core/default-order/#overriding-default-order - * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 - * - * @var array - */ - private $order; - - /** - * @see https://api-platform.com/docs/core/dto/#specifying-an-input-or-an-output-data-representation - * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 - * - * @var string|false - */ - private $output; - - /** - * @see https://api-platform.com/docs/core/pagination/#for-a-specific-resource-1 - * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 - * - * @var bool - */ - private $paginationClientEnabled; - - /** - * @see https://api-platform.com/docs/core/pagination/#for-a-specific-resource-3 - * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 - * - * @var bool - */ - private $paginationClientItemsPerPage; - - /** - * @see https://api-platform.com/docs/core/pagination/#for-a-specific-resource-6 - * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 - * - * @var bool - */ - private $paginationClientPartial; - - /** - * @see https://api-platform.com/docs/core/pagination/#cursor-based-pagination - * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 - * - * @var array - */ - private $paginationViaCursor; - - /** - * @see https://api-platform.com/docs/core/pagination/#for-a-specific-resource - * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 - * - * @var bool - */ - private $paginationEnabled; - - /** - * @see https://api-platform.com/docs/core/pagination/#controlling-the-behavior-of-the-doctrine-orm-paginator - * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 - * - * @var bool - */ - private $paginationFetchJoinCollection; - - /** - * @see https://api-platform.com/docs/core/pagination/#changing-the-number-of-items-per-page - * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 - * - * @var int - */ - private $paginationItemsPerPage; - - /** - * @see https://api-platform.com/docs/core/pagination/#changing-maximum-items-per-page - * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 - * - * @var int - */ - private $paginationMaximumItemsPerPage; - - /** - * @see https://api-platform.com/docs/core/performance/#partial-pagination - * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 - * - * @var bool - */ - private $paginationPartial; - - /** - * @see https://api-platform.com/docs/core/operations/#prefixing-all-routes-of-all-operations - * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 - * - * @var string - */ - private $routePrefix; - - /** - * @see https://api-platform.com/docs/core/security - * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 - * - * @var string - */ - private $security; - - /** - * @see https://api-platform.com/docs/core/security/#configuring-the-access-control-error-message - * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 - * - * @var string - */ - private $securityMessage; - - /** - * @see https://api-platform.com/docs/core/security/#executing-access-control-rules-after-denormalization - * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 - * - * @var string - */ - private $securityPostDenormalize; - - /** - * @see https://api-platform.com/docs/core/security/#configuring-the-access-control-error-message - * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 - * - * @var string - */ - private $securityPostDenormalizeMessage; - - /** - * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 - * - * @var bool - */ - private $stateless; - - /** - * @see https://api-platform.com/docs/core/deprecations/#setting-the-sunset-http-header-to-indicate-when-a-resource-or-an-operation-will-be-removed - * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 - * - * @var string - */ - private $sunset; - - /** - * @see https://api-platform.com/docs/core/swagger/#using-the-openapi-and-swagger-contexts - * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 - * - * @var array - */ - private $swaggerContext; - - /** - * @see https://api-platform.com/docs/core/validation/#using-validation-groups - * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 - */ - private $validationGroups; - - /** - * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 - * - * @var int - */ - private $urlGenerationStrategy; - - /** - * @param array|string $valuesOrDescription + * @param string $description * @param array $collectionOperations https://api-platform.com/docs/core/operations * @param array $graphql https://api-platform.com/docs/core/graphql * @param array $itemOperations https://api-platform.com/docs/core/operations @@ -552,19 +219,20 @@ public function __construct( ?array $swaggerContext = null, ?array $validationGroups = null, ?int $urlGenerationStrategy = null -) - { + ) { if (!is_array($description)) { - foreach (self::PUBLIC_PROPERTIES as $prop) { - $this->$prop = $$prop; + [$publicProperties, $configurableAttributes] = self::getConfigMetadata(); + + foreach ($publicProperties as $prop => $_) { + $this->{$prop} = $$prop; } $description = []; - foreach (array_diff(self::CONFIGURABLE_DEFAULTS, self::PUBLIC_PROPERTIES) as $attribute) { + foreach ($configurableAttributes as $attribute => $_) { $description[$attribute] = $$attribute; } } - $this->hydrateAttributes($description); + $this->hydrateAttributes($description ?? []); } } diff --git a/src/Annotation/AttributesHydratorTrait.php b/src/Annotation/AttributesHydratorTrait.php index 1211f73b6a1..9b13ab480b7 100644 --- a/src/Annotation/AttributesHydratorTrait.php +++ b/src/Annotation/AttributesHydratorTrait.php @@ -26,6 +26,34 @@ */ trait AttributesHydratorTrait { + private static $configMetadata; + + /** + * @internal + */ + public static function getConfigMetadata(): array + { + if (null !== self::$configMetadata) { + return self::$configMetadata; + } + + $rc = new \ReflectionClass(self::class); + + $publicProperties = []; + foreach ($rc->getProperties(\ReflectionProperty::IS_PUBLIC) as $reflectionProperty) { + $publicProperties[$reflectionProperty->getName()] = true; + } + + $configurableAttributes = []; + foreach ($rc->getConstructor()->getParameters() as $param) { + if (!isset($publicProperties[$name = $param->getName()])) { + $configurableAttributes[$name] = true; + } + } + + return [$publicProperties, $configurableAttributes]; + } + /** * @var array */ @@ -41,24 +69,22 @@ private function hydrateAttributes(array $values): void unset($values['attributes']); } - if (\array_key_exists('accessControl', $values)) { - $values['security'] = $values['accessControl']; - @trigger_error('Attribute "accessControl" is deprecated in annotation since API Platform 2.5, prefer using "security" attribute instead', E_USER_DEPRECATED); - unset($values['accessControl']); - } - if (\array_key_exists('accessControlMessage', $values)) { - $values['securityMessage'] = $values['accessControlMessage']; - @trigger_error('Attribute "accessControlMessage" is deprecated in annotation since API Platform 2.5, prefer using "securityMessage" attribute instead', E_USER_DEPRECATED); - unset($values['accessControlMessage']); + foreach (self::$deprecatedAttributes as $deprecatedAttribute => $options) { + if (\array_key_exists($deprecatedAttribute, $values)) { + $values[$options[0]] = $values[$deprecatedAttribute]; + @trigger_error(sprintf('Attribute "%s" is deprecated in annotation since API Platform %s, prefer using "%s" attribute instead', $deprecatedAttribute, $options[1], $options[0]), E_USER_DEPRECATED); + unset($values[$deprecatedAttribute]); + } } + [$publicProperties, $configurableAttributes] = self::getConfigMetadata(); foreach ($values as $key => $value) { $key = (string) $key; - if (!property_exists($this, $key)) { + if (!isset($publicProperties[$key]) && !isset($configurableAttributes[$key]) && !isset(self::$deprecatedAttributes[$key])) { throw new InvalidArgumentException(sprintf('Unknown property "%s" on annotation "%s".', $key, self::class)); } - if ((new \ReflectionProperty($this, $key))->isPublic()) { + if (isset($publicProperties[$key])) { $this->{$key} = $value; continue; } diff --git a/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index 04b9ff08090..15c9cebba52 100644 --- a/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Core\Bridge\Symfony\Bundle\DependencyInjection; +use ApiPlatform\Core\Annotation\ApiResource; use ApiPlatform\Core\Api\FilterInterface; use ApiPlatform\Core\Api\UrlGeneratorInterface; use ApiPlatform\Core\Bridge\Doctrine\MongoDbOdm\Extension\AggregationCollectionExtensionInterface; @@ -242,21 +243,19 @@ private function getPaginationDefaults(array $defaults, array $collectionPaginat private function normalizeDefaults(array $defaults): array { - $normalizedDefaults = ['attributes' => []]; - $rootLevelOptions = [ - 'description', - 'iri', - 'item_operations', - 'collection_operations', - 'graphql', - ]; + $normalizedDefaults = ['attributes' => $defaults['attributes'] ?? []]; + unset($defaults['attributes']); + + [$publicProperties,] = ApiResource::getConfigMetadata(); foreach ($defaults as $option => $value) { - if (\in_array($option, $rootLevelOptions, true)) { + if (isset($publicProperties[$option])) { $normalizedDefaults[$option] = $value; - } else { - $normalizedDefaults['attributes'][$option] = $value; + + continue; } + + $normalizedDefaults['attributes'][$option] = $value; } if (!\array_key_exists('stateless', $defaults)) { diff --git a/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php b/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php index d46115b8c64..ee5381b10a0 100644 --- a/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php +++ b/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php @@ -598,7 +598,7 @@ private function addDefaultsSection(ArrayNodeDefinition $rootNode): void return $normalizedDefaults; }); - foreach (ApiResource::CONFIGURABLE_DEFAULTS as $attribute) { + foreach (ApiResource::getConfigMetadata()[1] as $attribute => $_) { $snakeCased = $nameConverter->normalize($attribute); $defaultsNode->children()->variableNode($snakeCased); } diff --git a/src/Metadata/Property/Factory/AnnotationPropertyMetadataFactory.php b/src/Metadata/Property/Factory/AnnotationPropertyMetadataFactory.php index 9362aa5d6e2..0a8efcbe4a2 100644 --- a/src/Metadata/Property/Factory/AnnotationPropertyMetadataFactory.php +++ b/src/Metadata/Property/Factory/AnnotationPropertyMetadataFactory.php @@ -29,7 +29,7 @@ final class AnnotationPropertyMetadataFactory implements PropertyMetadataFactory private $reader; private $decorated; - public function __construct(Reader $reader, PropertyMetadataFactoryInterface $decorated = null) + public function __construct(Reader $reader = null, PropertyMetadataFactoryInterface $decorated = null) { $this->reader = $reader; $this->decorated = $decorated; @@ -56,7 +56,13 @@ public function create(string $resourceClass, string $property, array $options = } if ($reflectionClass->hasProperty($property)) { - $annotation = $this->reader->getPropertyAnnotation($reflectionClass->getProperty($property), ApiProperty::class); + $annotation = null; + $reflectionProperty = $reflectionClass->getProperty($property); + if (\PHP_VERSION_ID >= 80000 && $attributes = $reflectionProperty->getAttributes(ApiProperty::class)) { + $annotation = $attributes[0]->newInstance(); + } elseif (null !== $this->reader) { + $annotation = $this->reader->getPropertyAnnotation($reflectionProperty, ApiProperty::class); + } if ($annotation instanceof ApiProperty) { return $this->createMetadata($annotation, $parentPropertyMetadata); @@ -74,7 +80,12 @@ public function create(string $resourceClass, string $property, array $options = continue; } - $annotation = $this->reader->getMethodAnnotation($reflectionMethod, ApiProperty::class); + $annotation = null; + if (\PHP_VERSION_ID >= 80000 && $attributes = $reflectionMethod->getAttributes(ApiProperty::class)) { + $annotation = $attributes[0]->newInstance(); + } elseif (null !== $this->reader) { + $annotation = $this->reader->getMethodAnnotation($reflectionMethod, ApiProperty::class); + } if ($annotation instanceof ApiProperty) { return $this->createMetadata($annotation, $parentPropertyMetadata); diff --git a/src/Metadata/Property/Factory/AnnotationPropertyNameCollectionFactory.php b/src/Metadata/Property/Factory/AnnotationPropertyNameCollectionFactory.php index a847c13e8ef..fd1aa59e67f 100644 --- a/src/Metadata/Property/Factory/AnnotationPropertyNameCollectionFactory.php +++ b/src/Metadata/Property/Factory/AnnotationPropertyNameCollectionFactory.php @@ -30,7 +30,7 @@ final class AnnotationPropertyNameCollectionFactory implements PropertyNameColle private $decorated; private $reflection; - public function __construct(Reader $reader, PropertyNameCollectionFactoryInterface $decorated = null) + public function __construct(Reader $reader = null, PropertyNameCollectionFactoryInterface $decorated = null) { $this->reader = $reader; $this->decorated = $decorated; @@ -66,7 +66,10 @@ public function create(string $resourceClass, array $options = []): PropertyName // Properties foreach ($reflectionClass->getProperties() as $reflectionProperty) { - if (null !== $this->reader->getPropertyAnnotation($reflectionProperty, ApiProperty::class)) { + if ( + (\PHP_VERSION_ID >= 80000 && $reflectionProperty->getAttributes(ApiProperty::class)) || + (null !== $this->reader && null !== $this->reader->getPropertyAnnotation($reflectionProperty, ApiProperty::class)) + ) { $propertyNames[$reflectionProperty->name] = $reflectionProperty->name; } } @@ -82,7 +85,13 @@ public function create(string $resourceClass, array $options = []): PropertyName $propertyName = lcfirst($propertyName); } - if (null !== $propertyName && null !== $this->reader->getMethodAnnotation($reflectionMethod, ApiProperty::class)) { + if ( + null !== $propertyName && + ( + (\PHP_VERSION_ID >= 80000 && $reflectionMethod->getAttributes(ApiProperty::class)) || + (null !== $this->reader && null !== $this->reader->getMethodAnnotation($reflectionMethod, ApiProperty::class)) + ) + ) { $propertyNames[$propertyName] = $propertyName; } } diff --git a/src/Metadata/Resource/Factory/AnnotationResourceNameCollectionFactory.php b/src/Metadata/Resource/Factory/AnnotationResourceNameCollectionFactory.php index 102da335e0f..949e3b0a844 100644 --- a/src/Metadata/Resource/Factory/AnnotationResourceNameCollectionFactory.php +++ b/src/Metadata/Resource/Factory/AnnotationResourceNameCollectionFactory.php @@ -32,7 +32,7 @@ final class AnnotationResourceNameCollectionFactory implements ResourceNameColle /** * @param string[] $paths */ - public function __construct(Reader $reader, array $paths, ResourceNameCollectionFactoryInterface $decorated = null) + public function __construct(Reader $reader = null, array $paths, ResourceNameCollectionFactoryInterface $decorated = null) { $this->reader = $reader; $this->paths = $paths; @@ -53,7 +53,10 @@ public function create(): ResourceNameCollection } foreach (ReflectionClassRecursiveIterator::getReflectionClassesFromDirectories($this->paths) as $className => $reflectionClass) { - if ($this->reader->getClassAnnotation($reflectionClass, ApiResource::class)) { + if ( + (\PHP_VERSION_ID >= 80000 && $reflectionClass->getAttributes(ApiResource::class)) || + (null !== $this->reader && $this->reader->getClassAnnotation($reflectionClass, ApiResource::class)) + ) { $classes[$className] = true; } } diff --git a/tests/Annotation/ApiPropertyTest.php b/tests/Annotation/ApiPropertyTest.php index aa550d42e4f..f4c1ec790c0 100644 --- a/tests/Annotation/ApiPropertyTest.php +++ b/tests/Annotation/ApiPropertyTest.php @@ -70,4 +70,37 @@ public function testConstruct() 'unknown' => 'unknown', ], $property->attributes); } + + /** + * @requires PHP 8.0 + */ + public function testConstructAttribute() + { + $property = eval(<<<'PHP' +return new \ApiPlatform\Core\Annotation\ApiProperty( + deprecationReason: 'this field is deprecated', + fetchable: true, + fetchEager: false, + jsonldContext: ['foo' => 'bar'], + security: 'is_granted(\'ROLE_ADMIN\')', + swaggerContext: ['foo' => 'baz'], + openapiContext: ['foo' => 'baz'], + push: true, + attributes: ['unknown' => 'unknown', 'fetchable' => false] +); +PHP + ); + + $this->assertEquals([ + 'deprecation_reason' => 'this field is deprecated', + 'fetchable' => false, + 'fetch_eager' => false, + 'jsonld_context' => ['foo' => 'bar'], + 'security' => 'is_granted(\'ROLE_ADMIN\')', + 'swagger_context' => ['foo' => 'baz'], + 'openapi_context' => ['foo' => 'baz'], + 'push' => true, + 'unknown' => 'unknown', + ], $property->attributes); + } } diff --git a/tests/Annotation/ApiResourceTest.php b/tests/Annotation/ApiResourceTest.php index 5c8340549f5..82d2c210ec7 100644 --- a/tests/Annotation/ApiResourceTest.php +++ b/tests/Annotation/ApiResourceTest.php @@ -57,7 +57,6 @@ public function testConstruct() 'paginationEnabled' => true, 'paginationFetchJoinCollection' => true, 'paginationItemsPerPage' => 42, - 'maximumItemsPerPage' => 42, // deprecated, see paginationMaximumItemsPerPage 'paginationMaximumItemsPerPage' => 50, 'paginationPartial' => true, 'routePrefix' => '/foo', @@ -100,7 +99,6 @@ public function testConstruct() 'pagination_enabled' => true, 'pagination_fetch_join_collection' => true, 'pagination_items_per_page' => 42, - 'maximum_items_per_page' => 42, 'pagination_maximum_items_per_page' => 50, 'pagination_partial' => true, 'route_prefix' => '/foo', diff --git a/tests/Fixtures/TestBundle/Entity/DummyPhp8.php b/tests/Fixtures/TestBundle/Entity/DummyPhp8.php index 563c37af068..28d3f91d5ae 100644 --- a/tests/Fixtures/TestBundle/Entity/DummyPhp8.php +++ b/tests/Fixtures/TestBundle/Entity/DummyPhp8.php @@ -19,8 +19,12 @@ #[ApiResource(description: "Hey PHP 8")] class DummyPhp8 { - /** - * @ApiProperty(identifier=true) - */ + #[ApiProperty(identifier: true, description: 'the identifier')] public $id; + + #[ApiProperty(description: 'a foo')] + public function getFoo(): int + { + return 0; + } } diff --git a/tests/Metadata/Property/Factory/AnnotationPropertyMetadataFactoryTest.php b/tests/Metadata/Property/Factory/AnnotationPropertyMetadataFactoryTest.php index 6da8905aee7..aa906801559 100644 --- a/tests/Metadata/Property/Factory/AnnotationPropertyMetadataFactoryTest.php +++ b/tests/Metadata/Property/Factory/AnnotationPropertyMetadataFactoryTest.php @@ -19,6 +19,7 @@ use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Property\PropertyMetadata; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyPhp8; use ApiPlatform\Core\Tests\ProphecyTrait; use Doctrine\Common\Annotations\Reader; use PHPUnit\Framework\TestCase; @@ -50,6 +51,21 @@ public function testCreateProperty($reader, $decorated, string $description) $this->assertEquals(['foo' => 'bar'], $metadata->getAttributes()); } + /** + * @requires PHP 8.0 + */ + public function testCreateAttribute() + { + $factory = new AnnotationPropertyMetadataFactory(); + + $metadata = $factory->create(DummyPhp8::class, 'id'); + $this->assertTrue($metadata->isIdentifier()); + $this->assertSame('the identifier', $metadata->getDescription()); + + $metadata = $factory->create(DummyPhp8::class, 'foo'); + $this->assertSame('a foo', $metadata->getDescription()); + } + public function dependenciesProvider(): array { $annotation = new ApiProperty(); diff --git a/tests/Metadata/Property/Factory/AnnotationPropertyNameCollectionFactoryTest.php b/tests/Metadata/Property/Factory/AnnotationPropertyNameCollectionFactoryTest.php index c5e3280de4a..3aeccc4c767 100644 --- a/tests/Metadata/Property/Factory/AnnotationPropertyNameCollectionFactoryTest.php +++ b/tests/Metadata/Property/Factory/AnnotationPropertyNameCollectionFactoryTest.php @@ -19,6 +19,7 @@ use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Core\Metadata\Property\PropertyNameCollection; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyPhp8; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\UpperCaseIdentifierDummy; use ApiPlatform\Core\Tests\ProphecyTrait; use Doctrine\Common\Annotations\Reader; @@ -66,6 +67,17 @@ public function dependenciesProvider(): array ]; } + /** + * @requires PHP 8.0 + */ + public function testCreateAttribute() + { + $factory = new AnnotationPropertyNameCollectionFactory(); + $metadata = $factory->create(DummyPhp8::class); + + $this->assertSame(['id', 'foo'], iterator_to_array($metadata)); + } + /** * @dataProvider upperCaseDependenciesProvider */ diff --git a/tests/Metadata/Resource/Factory/AnnotationResourceNameCollectionFactoryTest.php b/tests/Metadata/Resource/Factory/AnnotationResourceNameCollectionFactoryTest.php index 6fb863ffa09..ac542c9175c 100644 --- a/tests/Metadata/Resource/Factory/AnnotationResourceNameCollectionFactoryTest.php +++ b/tests/Metadata/Resource/Factory/AnnotationResourceNameCollectionFactoryTest.php @@ -38,4 +38,16 @@ public function testCreate() $this->assertEquals(new ResourceNameCollection(['foo', 'bar']), $metadata->create()); } + + /** + * @requires PHP 8.0 + */ + public function testCreateAttribute() + { + $decorated = $this->prophesize(ResourceNameCollectionFactoryInterface::class); + $decorated->create()->willReturn(new ResourceNameCollection(['foo', 'bar']))->shouldBeCalled(); + + $metadata = new AnnotationResourceNameCollectionFactory(null, [], $decorated->reveal()); + $this->assertEquals(new ResourceNameCollection(['foo', 'bar']), $metadata->create()); + } } From 8a5494d05e0fd6ffb3c3efc50728eb70c11b9207 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Dunglas?= Date: Fri, 27 Nov 2020 11:56:43 +0100 Subject: [PATCH 154/160] tests: fix tests --- .php_cs.dist | 1 + phpstan.neon.dist | 10 ++++ src/Annotation/ApiProperty.php | 45 +++++++++--------- src/Annotation/ApiResource.php | 87 +++++++++++++++++----------------- 4 files changed, 76 insertions(+), 67 deletions(-) diff --git a/.php_cs.dist b/.php_cs.dist index cbd76a6fc83..bdd5ffa42c0 100644 --- a/.php_cs.dist +++ b/.php_cs.dist @@ -29,6 +29,7 @@ return PhpCsFixer\Config::create() '@PHPUnit60Migration:risky' => true, '@Symfony' => true, '@Symfony:risky' => true, + 'single_line_comment_style' => false, // Temporary fix for compatibility with PHP 8 attributes, see https://github.com/FriendsOfPHP/PHP-CS-Fixer/pull/5284 'align_multiline_comment' => [ 'comment_type' => 'phpdocs_like', ], diff --git a/phpstan.neon.dist b/phpstan.neon.dist index fa05eb4d3fb..211065f002d 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -21,6 +21,9 @@ parameters: - tests/Bridge/NelmioApiDoc/* - src/Bridge/FosUser/* # BC layer + - tests/Annotation/ApiResourceTest.php + - tests/Annotation/ApiPropertyTest.php + - tests/Metadata/Resource/Factory/AnnotationResourceMetadataFactoryTest.php - tests/Fixtures/TestBundle/BrowserKit/Client.php # The Symfony Configuration API isn't good enough to be analysed - src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php @@ -101,6 +104,13 @@ parameters: message: '#Property Doctrine\\ORM\\Mapping\\ClassMetadataInfo::\$fieldMappings \(array.*\)>\) does not accept array\(.*\)\.#' path: tests/Bridge/Doctrine/Orm/Metadata/Property/DoctrineOrmPropertyMetadataFactoryTest.php + # Expected, due to PHP 8 attributes + - '#ReflectionProperty::getAttributes\(\)#' + - '#ReflectionMethod::getAttributes\(\)#' + - '#ReflectionClass::getAttributes\(\)#' + - '#Constructor of class ApiPlatform\\Core\\Annotation\\ApiResource has an unused parameter#' + - '#Constructor of class ApiPlatform\\Core\\Annotation\\ApiProperty has an unused parameter#' + # Expected, due to optional interfaces - '#Method ApiPlatform\\Core\\Bridge\\Doctrine\\Orm\\Extension\\QueryCollectionExtensionInterface::applyToCollection\(\) invoked with 5 parameters, 3-4 required\.#' - '#Method ApiPlatform\\Core\\Bridge\\Doctrine\\Orm\\Extension\\QueryResult(Item|Collection)ExtensionInterface::getResult\(\) invoked with 4 parameters, 1 required\.#' diff --git a/src/Annotation/ApiProperty.php b/src/Annotation/ApiProperty.php index cd2ae8ab31d..1d744926a04 100644 --- a/src/Annotation/ApiProperty.php +++ b/src/Annotation/ApiProperty.php @@ -94,25 +94,24 @@ final class ApiProperty public $example; /** - * @param string $description - * @param bool $readable - * @param bool $writable - * @param bool $readableLink - * @param bool $writableLink - * @param bool $required - * @param bool $iri - * @param bool $identifier - * @param string|int|float|bool|array $default + * @param string $description + * @param bool $readable + * @param bool $writable + * @param bool $readableLink + * @param bool $writableLink + * @param bool $required + * @param string $iri + * @param bool $identifier + * @param string|int|float|bool|array $default * @param string|int|float|bool|array|null $example - * - * @param string $deprecationReason - * @param bool $fetchable - * @param bool $fetchEager - * @param array $jsonldContext - * @param array $openapiContext - * @param bool $push - * @param string $security - * @param array $swaggerContext + * @param string $deprecationReason + * @param bool $fetchable + * @param bool $fetchEager + * @param array $jsonldContext + * @param array $openapiContext + * @param bool $push + * @param string $security + * @param array $swaggerContext * * @throws InvalidArgumentException */ @@ -121,7 +120,7 @@ public function __construct( ?bool $readable = null, ?bool $writable = null, ?bool $readableLink = null, - ?bool $writableLink = null, + ?bool $writableLink = null, ?bool $required = null, ?string $iri = null, ?bool $identifier = null, @@ -132,23 +131,23 @@ public function __construct( ?array $attributes = null, ?string $deprecationReason = null, ?bool $fetchable = null, - ?bool $fetchEager = null, + ?bool $fetchEager = null, ?array $jsonldContext = null, ?array $openapiContext = null, ?bool $push = null, ?string $security = null, ?array $swaggerContext = null ) { - if (!is_array($description)) { + if (!\is_array($description)) { // @phpstan-ignore-line Doctrine annotations support [$publicProperties, $configurableAttributes] = self::getConfigMetadata(); foreach ($publicProperties as $prop => $_) { - $this->{$prop} = $$prop; + $this->{$prop} = ${$prop}; } $description = []; foreach ($configurableAttributes as $attribute => $_) { - $description[$attribute] = $$attribute; + $description[$attribute] = ${$attribute}; } } diff --git a/src/Annotation/ApiResource.php b/src/Annotation/ApiResource.php index 72a86338082..1208080d692 100644 --- a/src/Annotation/ApiResource.php +++ b/src/Annotation/ApiResource.php @@ -129,47 +129,46 @@ final class ApiResource public $subresourceOperations; /** - * @param string $description - * @param array $collectionOperations https://api-platform.com/docs/core/operations - * @param array $graphql https://api-platform.com/docs/core/graphql - * @param array $itemOperations https://api-platform.com/docs/core/operations - * @param array $subresourceOperations https://api-platform.com/docs/core/subresources - * - * @param array $cacheHeaders https://api-platform.com/docs/core/performance/#setting-custom-http-cache-headers - * @param array $denormalizationContext https://api-platform.com/docs/core/serialization/#using-serialization-groups - * @param string $deprecationReason https://api-platform.com/docs/core/deprecations/#deprecating-resource-classes-operations-and-properties - * @param bool $elasticsearch https://api-platform.com/docs/core/elasticsearch/ - * @param bool $fetchPartial https://api-platform.com/docs/core/performance/#fetch-partial - * @param bool $forceEager https://api-platform.com/docs/core/performance/#force-eager - * @param array $formats https://api-platform.com/docs/core/content-negotiation/#configuring-formats-for-a-specific-resource-or-operation - * @param string[] $filters https://api-platform.com/docs/core/filters/#doctrine-orm-and-mongodb-odm-filters - * @param string[] $hydraContext https://api-platform.com/docs/core/extending-jsonld-context/#hydra - * @param string|false $input https://api-platform.com/docs/core/dto/#specifying-an-input-or-an-output-data-representation - * @param bool|array $mercure https://api-platform.com/docs/core/mercure - * @param bool $messenger https://api-platform.com/docs/core/messenger/#dispatching-a-resource-through-the-message-bus - * @param array $normalizationContext https://api-platform.com/docs/core/serialization/#using-serialization-groups - * @param array $openapiContext https://api-platform.com/docs/core/swagger/#using-the-openapi-and-swagger-contexts - * @param array $order https://api-platform.com/docs/core/default-order/#overriding-default-order - * @param string|false $output https://api-platform.com/docs/core/dto/#specifying-an-input-or-an-output-data-representation - * @param bool $paginationClientEnabled https://api-platform.com/docs/core/pagination/#for-a-specific-resource-1 - * @param bool $paginationClientItemsPerPage https://api-platform.com/docs/core/pagination/#for-a-specific-resource-3 - * @param bool $paginationClientPartial https://api-platform.com/docs/core/pagination/#for-a-specific-resource-6 - * @param array $paginationViaCursor https://api-platform.com/docs/core/pagination/#cursor-based-pagination - * @param bool $paginationEnabled https://api-platform.com/docs/core/pagination/#for-a-specific-resource - * @param bool $paginationFetchJoinCollection https://api-platform.com/docs/core/pagination/#controlling-the-behavior-of-the-doctrine-orm-paginator - * @param int $paginationItemsPerPage https://api-platform.com/docs/core/pagination/#changing-the-number-of-items-per-page - * @param int $paginationMaximumItemsPerPage https://api-platform.com/docs/core/pagination/#changing-maximum-items-per-page - * @param bool $paginationPartial https://api-platform.com/docs/core/performance/#partial-pagination - * @param string $routePrefix https://api-platform.com/docs/core/operations/#prefixing-all-routes-of-all-operations - * @param string $security https://api-platform.com/docs/core/security - * @param string $securityMessage https://api-platform.com/docs/core/security/#configuring-the-access-control-error-message - * @param string $securityPostDenormalize https://api-platform.com/docs/core/security/#executing-access-control-rules-after-denormalization - * @param string $securityPostDenormalizeMessage https://api-platform.com/docs/core/security/#configuring-the-access-control-error-message - * @param bool $stateless - * @param string $sunset https://api-platform.com/docs/core/deprecations/#setting-the-sunset-http-header-to-indicate-when-a-resource-or-an-operation-will-be-removed - * @param array $swaggerContext https://api-platform.com/docs/core/swagger/#using-the-openapi-and-swagger-contexts - * @param array $validationGroups https://api-platform.com/docs/core/validation/#using-validation-groups - * @param int $urlGenerationStrategy + * @param string $description + * @param array $collectionOperations https://api-platform.com/docs/core/operations + * @param array $graphql https://api-platform.com/docs/core/graphql + * @param array $itemOperations https://api-platform.com/docs/core/operations + * @param array $subresourceOperations https://api-platform.com/docs/core/subresources + * @param array $cacheHeaders https://api-platform.com/docs/core/performance/#setting-custom-http-cache-headers + * @param array $denormalizationContext https://api-platform.com/docs/core/serialization/#using-serialization-groups + * @param string $deprecationReason https://api-platform.com/docs/core/deprecations/#deprecating-resource-classes-operations-and-properties + * @param bool $elasticsearch https://api-platform.com/docs/core/elasticsearch/ + * @param bool $fetchPartial https://api-platform.com/docs/core/performance/#fetch-partial + * @param bool $forceEager https://api-platform.com/docs/core/performance/#force-eager + * @param array $formats https://api-platform.com/docs/core/content-negotiation/#configuring-formats-for-a-specific-resource-or-operation + * @param string[] $filters https://api-platform.com/docs/core/filters/#doctrine-orm-and-mongodb-odm-filters + * @param string[] $hydraContext https://api-platform.com/docs/core/extending-jsonld-context/#hydra + * @param string|false $input https://api-platform.com/docs/core/dto/#specifying-an-input-or-an-output-data-representation + * @param bool|array $mercure https://api-platform.com/docs/core/mercure + * @param bool $messenger https://api-platform.com/docs/core/messenger/#dispatching-a-resource-through-the-message-bus + * @param array $normalizationContext https://api-platform.com/docs/core/serialization/#using-serialization-groups + * @param array $openapiContext https://api-platform.com/docs/core/swagger/#using-the-openapi-and-swagger-contexts + * @param array $order https://api-platform.com/docs/core/default-order/#overriding-default-order + * @param string|false $output https://api-platform.com/docs/core/dto/#specifying-an-input-or-an-output-data-representation + * @param bool $paginationClientEnabled https://api-platform.com/docs/core/pagination/#for-a-specific-resource-1 + * @param bool $paginationClientItemsPerPage https://api-platform.com/docs/core/pagination/#for-a-specific-resource-3 + * @param bool $paginationClientPartial https://api-platform.com/docs/core/pagination/#for-a-specific-resource-6 + * @param array $paginationViaCursor https://api-platform.com/docs/core/pagination/#cursor-based-pagination + * @param bool $paginationEnabled https://api-platform.com/docs/core/pagination/#for-a-specific-resource + * @param bool $paginationFetchJoinCollection https://api-platform.com/docs/core/pagination/#controlling-the-behavior-of-the-doctrine-orm-paginator + * @param int $paginationItemsPerPage https://api-platform.com/docs/core/pagination/#changing-the-number-of-items-per-page + * @param int $paginationMaximumItemsPerPage https://api-platform.com/docs/core/pagination/#changing-maximum-items-per-page + * @param bool $paginationPartial https://api-platform.com/docs/core/performance/#partial-pagination + * @param string $routePrefix https://api-platform.com/docs/core/operations/#prefixing-all-routes-of-all-operations + * @param string $security https://api-platform.com/docs/core/security + * @param string $securityMessage https://api-platform.com/docs/core/security/#configuring-the-access-control-error-message + * @param string $securityPostDenormalize https://api-platform.com/docs/core/security/#executing-access-control-rules-after-denormalization + * @param string $securityPostDenormalizeMessage https://api-platform.com/docs/core/security/#configuring-the-access-control-error-message + * @param bool $stateless + * @param string $sunset https://api-platform.com/docs/core/deprecations/#setting-the-sunset-http-header-to-indicate-when-a-resource-or-an-operation-will-be-removed + * @param array $swaggerContext https://api-platform.com/docs/core/swagger/#using-the-openapi-and-swagger-contexts + * @param array $validationGroups https://api-platform.com/docs/core/validation/#using-validation-groups + * @param int $urlGenerationStrategy * * @throws InvalidArgumentException */ @@ -220,16 +219,16 @@ public function __construct( ?array $validationGroups = null, ?int $urlGenerationStrategy = null ) { - if (!is_array($description)) { + if (!\is_array($description)) { // @phpstan-ignore-line Doctrine annotations support [$publicProperties, $configurableAttributes] = self::getConfigMetadata(); foreach ($publicProperties as $prop => $_) { - $this->{$prop} = $$prop; + $this->{$prop} = ${$prop}; } $description = []; foreach ($configurableAttributes as $attribute => $_) { - $description[$attribute] = $$attribute; + $description[$attribute] = ${$attribute}; } } From c41db56d81a0265f22cf86f483132504e8d62cb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20FIDRY?= Date: Sat, 28 Nov 2020 14:02:47 +0100 Subject: [PATCH 155/160] Update AbstractConstraintViolationListNormalizer.php --- src/Serializer/AbstractConstraintViolationListNormalizer.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Serializer/AbstractConstraintViolationListNormalizer.php b/src/Serializer/AbstractConstraintViolationListNormalizer.php index 4d71b1f1839..67960646c0f 100644 --- a/src/Serializer/AbstractConstraintViolationListNormalizer.php +++ b/src/Serializer/AbstractConstraintViolationListNormalizer.php @@ -63,6 +63,7 @@ protected function getMessagesAndViolations(ConstraintViolationListInterface $co $violationData = [ 'propertyPath' => $this->nameConverter ? $this->nameConverter->normalize($violation->getPropertyPath(), $class, static::FORMAT) : $violation->getPropertyPath(), 'message' => $violation->getMessage(), + 'code' => $violation->getCode(), ]; $constraint = $violation->getConstraint(); From 30218e42ef652182f86068c7aaf0eff619f1ef6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20FIDRY?= Date: Sun, 29 Nov 2020 13:25:15 +0100 Subject: [PATCH 156/160] Fix tests --- features/hal/problem.feature | 3 ++- features/hydra/error.feature | 3 ++- features/main/validation.feature | 6 ++++-- .../Hydra/Serializer/ConstraintViolationNormalizerTest.php | 4 +++- .../Serializer/ConstraintViolationNormalizerTest.php | 4 +++- 5 files changed, 14 insertions(+), 6 deletions(-) diff --git a/features/hal/problem.feature b/features/hal/problem.feature index 878d4b21b2f..9ee65614a96 100644 --- a/features/hal/problem.feature +++ b/features/hal/problem.feature @@ -22,7 +22,8 @@ Feature: Error handling valid according to RFC 7807 (application/problem+json) "violations": [ { "propertyPath": "name", - "message": "This value should not be blank." + "message": "This value should not be blank.", + "code": "c1051bb4-d103-4f74-8988-acbcafc7fdc3" } ] } diff --git a/features/hydra/error.feature b/features/hydra/error.feature index 79a3e9e9a04..2dafb3ae705 100644 --- a/features/hydra/error.feature +++ b/features/hydra/error.feature @@ -22,7 +22,8 @@ Feature: Error handling "violations": [ { "propertyPath": "name", - "message": "This value should not be blank." + "message": "This value should not be blank.", + "code": "c1051bb4-d103-4f74-8988-acbcafc7fdc3" } ] } diff --git a/features/main/validation.feature b/features/main/validation.feature index 95b41b7db8c..2d7e0532815 100644 --- a/features/main/validation.feature +++ b/features/main/validation.feature @@ -36,7 +36,8 @@ Feature: Using validations groups "violations": [ { "propertyPath": "name", - "message": "This value should not be null." + "message": "This value should not be null.", + "code": "ad32d13f-c3d4-423b-909a-857b961eb720" } ] } @@ -64,7 +65,8 @@ Feature: Using validations groups "violations": [ { "propertyPath": "title", - "message": "This value should not be null." + "message": "This value should not be null.", + "code": "ad32d13f-c3d4-423b-909a-857b961eb720" } ] } diff --git a/tests/Hydra/Serializer/ConstraintViolationNormalizerTest.php b/tests/Hydra/Serializer/ConstraintViolationNormalizerTest.php index 392c5ff43b2..2ad2080c01b 100644 --- a/tests/Hydra/Serializer/ConstraintViolationNormalizerTest.php +++ b/tests/Hydra/Serializer/ConstraintViolationNormalizerTest.php @@ -62,7 +62,7 @@ public function testNormalize(?array $fields, array $result) $constraint = new NotNull(); $constraint->payload = ['severity' => 'warning', 'anotherField2' => 'aValue']; $list = new ConstraintViolationList([ - new ConstraintViolation('a', 'b', [], 'c', 'd', 'e', null, null, $constraint), + new ConstraintViolation('a', 'b', [], 'c', 'd', 'e', null, 'f24bdbad0becef97a6887238aa58221c', $constraint), new ConstraintViolation('1', '2', [], '3', '4', '5'), ]); @@ -75,10 +75,12 @@ public function testNormalize(?array $fields, array $result) [ 'propertyPath' => '_d', 'message' => 'a', + 'code' => 'f24bdbad0becef97a6887238aa58221c', ], [ 'propertyPath' => '_4', 'message' => '1', + 'code' => null, ], ], ]; diff --git a/tests/Problem/Serializer/ConstraintViolationNormalizerTest.php b/tests/Problem/Serializer/ConstraintViolationNormalizerTest.php index 4444f0e61f0..ea4cf3baf99 100644 --- a/tests/Problem/Serializer/ConstraintViolationNormalizerTest.php +++ b/tests/Problem/Serializer/ConstraintViolationNormalizerTest.php @@ -53,7 +53,7 @@ public function testNormalize() $constraint = new NotNull(); $constraint->payload = ['severity' => 'warning', 'anotherField2' => 'aValue']; $list = new ConstraintViolationList([ - new ConstraintViolation('a', 'b', [], 'c', 'd', 'e', null, null, $constraint), + new ConstraintViolation('a', 'b', [], 'c', 'd', 'e', null, 'f24bdbad0becef97a6887238aa58221c', $constraint), new ConstraintViolation('1', '2', [], '3', '4', '5'), ]); @@ -65,6 +65,7 @@ public function testNormalize() [ 'propertyPath' => '_d', 'message' => 'a', + 'code' => 'f24bdbad0becef97a6887238aa58221c', 'payload' => [ 'severity' => 'warning', ], @@ -72,6 +73,7 @@ public function testNormalize() [ 'propertyPath' => '_4', 'message' => '1', + 'code' => null, ], ], ]; From 5573b2ac5de9a02ce46dbbb2cfc52cc79acdc783 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Sun, 29 Nov 2020 20:32:00 +0100 Subject: [PATCH 157/160] fix phpstan (#3859) --- tests/Bridge/Elasticsearch/DataProvider/PaginatorTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Bridge/Elasticsearch/DataProvider/PaginatorTest.php b/tests/Bridge/Elasticsearch/DataProvider/PaginatorTest.php index 9db9e1b60a2..a9516d21a66 100644 --- a/tests/Bridge/Elasticsearch/DataProvider/PaginatorTest.php +++ b/tests/Bridge/Elasticsearch/DataProvider/PaginatorTest.php @@ -154,7 +154,7 @@ public function testGetItemsPerPage() public function testGetIterator() { // set local cache - iterator_to_array($this->paginator); // @phpstan-ignore-line + iterator_to_array($this->paginator); self::assertEquals( array_map( From 2b1829667b787fc3d020527453b03bdd89923c84 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Mon, 30 Nov 2020 14:23:38 +0100 Subject: [PATCH 158/160] Merge 2.5 to fix phpstan (#3862) * fix phpstan (#3859) * Fix phpstan (#3861) * Fix phpstan * fix phpstan (#3859) --- phpstan.neon.dist | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 211065f002d..71435d0cf02 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -86,17 +86,7 @@ parameters: - message: '#Call to method PHPUnit\\Framework\\Assert::assertSame\(\) with array\(.+\) and array\(.+\) will always evaluate to false\.#' path: tests/Util/SortTraitTest.php - # https://github.com/phpstan/phpstan-symfony/issues/27 - - - message: '#Service "api_platform\.json_schema\.schema_factory" is private\.#' - path: src/Bridge/Symfony/Bundle/Test/ApiTestAssertionsTrait.php - - - message: '#Service "api_platform\.iri_converter" is private\.#' - path: src/Bridge/Symfony/Bundle/Test/ApiTestCase.php # https://github.com/phpstan/phpstan-symfony/issues/76 - - - message: '#Service "api_platform\.graphql\.fields_builder" is private\.#' - path: src/GraphQl/Type/TypeBuilder.php - message: '#Service "test" is not registered in the container\.#' path: tests/GraphQl/Type/TypesContainerTest.php From 8769b252da94d88ae05f8d71e958764462858994 Mon Sep 17 00:00:00 2001 From: soyuka Date: Sun, 29 Nov 2020 18:38:44 +0100 Subject: [PATCH 159/160] Deep object update consistency --- features/main/patch.feature | 31 ++++++++++ src/Serializer/AbstractItemNormalizer.php | 4 ++ src/Serializer/SerializerContextBuilder.php | 9 ++- tests/Behat/DoctrineContext.php | 23 ++++++++ .../Document/PatchDummyRelation.php | 57 ++++++++++++++++++ .../TestBundle/Entity/PatchDummyRelation.php | 59 +++++++++++++++++++ .../SerializerContextBuilderTest.php | 14 +++++ 7 files changed, 195 insertions(+), 2 deletions(-) create mode 100644 tests/Fixtures/TestBundle/Document/PatchDummyRelation.php create mode 100644 tests/Fixtures/TestBundle/Entity/PatchDummyRelation.php diff --git a/features/main/patch.feature b/features/main/patch.feature index c2f37f67bb6..28fce116892 100644 --- a/features/main/patch.feature +++ b/features/main/patch.feature @@ -28,3 +28,34 @@ Feature: Sending PATCH requets {"name": null} """ Then the JSON node "name" should not exist + + @createSchema + Scenario: Patch the relation + Given there is a PatchDummyRelation + When I add "Content-Type" header equal to "application/merge-patch+json" + And I send a "PATCH" request to "/patch_dummy_relations/1" with body: + """ + { + "related": { + "symfony": "A new name" + } + } + """ + Then print last JSON response + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the JSON should be equal to: + """ + { + "@context": "/contexts/PatchDummyRelation", + "@id": "/patch_dummy_relations/1", + "@type": "PatchDummyRelation", + "related": { + "@id": "/related_dummies/1", + "@type": "https://schema.org/Product", + "id": 1, + "symfony": "A new name" + } + } + """ diff --git a/src/Serializer/AbstractItemNormalizer.php b/src/Serializer/AbstractItemNormalizer.php index e30f9eab9fa..d69dcb41b60 100644 --- a/src/Serializer/AbstractItemNormalizer.php +++ b/src/Serializer/AbstractItemNormalizer.php @@ -577,6 +577,10 @@ protected function getAttributeValue($object, $attribute, $format = null, array $attributeValue = null; } + if ($context['api_denormalize'] ?? false) { + return $attributeValue; + } + $type = $propertyMetadata->getType(); if ( diff --git a/src/Serializer/SerializerContextBuilder.php b/src/Serializer/SerializerContextBuilder.php index fc93897d72f..0fdc2f8fdbf 100644 --- a/src/Serializer/SerializerContextBuilder.php +++ b/src/Serializer/SerializerContextBuilder.php @@ -63,7 +63,11 @@ public function createFromRequest(Request $request, bool $normalization, array $ if (!$normalization) { if (!isset($context['api_allow_update'])) { - $context['api_allow_update'] = \in_array($request->getMethod(), ['PUT', 'PATCH'], true); + $context['api_allow_update'] = \in_array($method = $request->getMethod(), ['PUT', 'PATCH'], true); + + if ($context['api_allow_update'] && 'PATCH' === $method) { + $context[AbstractItemNormalizer::DEEP_OBJECT_TO_POPULATE] = $context[AbstractItemNormalizer::DEEP_OBJECT_TO_POPULATE] ?? true; + } } if ('csv' === $request->getContentType()) { @@ -101,9 +105,10 @@ public function createFromRequest(Request $request, bool $normalization, array $ return $context; } + // TODO: We should always use `skip_null_values` but changing this would be a BC break, for now use it only when `merge-patch+json` is activated on a Resource foreach ($resourceMetadata->getItemOperations() as $operation) { if ('PATCH' === ($operation['method'] ?? '') && \in_array('application/merge-patch+json', $operation['input_formats']['json'] ?? [], true)) { - $context['skip_null_values'] = true; + $context[AbstractItemNormalizer::SKIP_NULL_VALUES] = true; break; } diff --git a/tests/Behat/DoctrineContext.php b/tests/Behat/DoctrineContext.php index b6be416ce39..a3e8c367268 100644 --- a/tests/Behat/DoctrineContext.php +++ b/tests/Behat/DoctrineContext.php @@ -61,6 +61,7 @@ use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\NetworkPathDummy as NetworkPathDummyDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\NetworkPathRelationDummy as NetworkPathRelationDummyDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\Order as OrderDocument; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\PatchDummyRelation as PatchDummyRelationDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\Person as PersonDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\PersonToPet as PersonToPetDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\Pet as PetDocument; @@ -128,6 +129,7 @@ use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\NetworkPathDummy; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\NetworkPathRelationDummy; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Order; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\PatchDummyRelation; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Person; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\PersonToPet; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Pet; @@ -1658,6 +1660,19 @@ public function thereIsAnInitializeInput(int $id) $this->manager->flush(); } + /** + * @Given there is a PatchDummyRelation + */ + public function thereIsAPatchDummyRelation() + { + $dummy = $this->buildPatchDummyRelation(); + $related = $this->buildRelatedDummy(); + $dummy->setRelated($related); + $this->manager->persist($related); + $this->manager->persist($dummy); + $this->manager->flush(); + } + private function isOrm(): bool { return null !== $this->schemaTool; @@ -2091,4 +2106,12 @@ private function buildInitializeInput() { return $this->isOrm() ? new InitializeInput() : new InitializeInputDocument(); } + + /** + * @return PatchDummyRelation|PatchDummyRelationDocument + */ + private function buildPatchDummyRelation() + { + return $this->isOrm() ? new PatchDummyRelation() : new PatchDummyRelationDocument(); + } } diff --git a/tests/Fixtures/TestBundle/Document/PatchDummyRelation.php b/tests/Fixtures/TestBundle/Document/PatchDummyRelation.php new file mode 100644 index 00000000000..e9368e1b6e5 --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/PatchDummyRelation.php @@ -0,0 +1,57 @@ + + * + * 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\Tests\Fixtures\TestBundle\Document; + +use ApiPlatform\Core\Annotation\ApiResource; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; +use Symfony\Component\Serializer\Annotation\Groups; + +/** + * @author Kévin Dunglas + * + * @ApiResource( + * attributes={ + * "normalization_context"={"groups"={"chicago"}}, + * "denormalization_context"={"groups"={"chicago"}}, + * }, + * itemOperations={ + * "get", + * "patch"={"input_formats"={"json"={"application/merge-patch+json"}, "jsonapi"}} + * } + * ) + * @ODM\Document + */ +class PatchDummyRelation +{ + /** + * @ODM\Id(strategy="INCREMENT", type="integer") + */ + public $id; + + /** + * @ODM\ReferenceOne(targetDocument=RelatedDummy::class) + * @Groups({"chicago"}) + */ + protected $related; + + public function getRelated() + { + return $this->related; + } + + public function setRelated(RelatedDummy $relatedDummy) + { + $this->related = $relatedDummy; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/PatchDummyRelation.php b/tests/Fixtures/TestBundle/Entity/PatchDummyRelation.php new file mode 100644 index 00000000000..aa830e506cc --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/PatchDummyRelation.php @@ -0,0 +1,59 @@ + + * + * 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\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Core\Annotation\ApiResource; +use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Serializer\Annotation\Groups; + +/** + * @author Kévin Dunglas + * + * @ApiResource( + * attributes={ + * "normalization_context"={"groups"={"chicago"}}, + * "denormalization_context"={"groups"={"chicago"}}, + * }, + * itemOperations={ + * "get", + * "patch"={"input_formats"={"json"={"application/merge-patch+json"}, "jsonapi"}} + * } + * ) + * @ORM\Entity + */ +class PatchDummyRelation +{ + /** + * @ORM\Id + * @ORM\Column(type="integer") + * @ORM\GeneratedValue(strategy="AUTO") + */ + public $id; + + /** + * @ORM\ManyToOne(targetEntity="RelatedDummy") + * @Groups({"chicago"}) + */ + protected $related; + + public function getRelated() + { + return $this->related; + } + + public function setRelated(RelatedDummy $relatedDummy) + { + $this->related = $relatedDummy; + } +} diff --git a/tests/Serializer/SerializerContextBuilderTest.php b/tests/Serializer/SerializerContextBuilderTest.php index a022a53454c..d73238214f2 100644 --- a/tests/Serializer/SerializerContextBuilderTest.php +++ b/tests/Serializer/SerializerContextBuilderTest.php @@ -48,8 +48,17 @@ protected function setUp(): void ] ); + $resourceMetadataWithPatch = new ResourceMetadata( + null, + null, + null, + ['patch' => ['method' => 'PATCH', 'input_formats' => ['json' => ['application/merge-patch+json']]]], + [] + ); + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); $resourceMetadataFactoryProphecy->create('Foo')->willReturn($resourceMetadata); + $resourceMetadataFactoryProphecy->create('FooWithPatch')->willReturn($resourceMetadataWithPatch); $this->builder = new SerializerContextBuilder($resourceMetadataFactoryProphecy->reveal()); } @@ -85,6 +94,11 @@ public function testCreateFromRequest() $request->attributes->replace(['_api_resource_class' => 'Foo', '_api_subresource_operation_name' => 'get', '_api_format' => 'xml', '_api_mime_type' => 'text/xml']); $expected = ['bar' => 'baz', 'subresource_operation_name' => 'get', 'resource_class' => 'Foo', 'request_uri' => '/bars/1/foos', 'operation_type' => 'subresource', 'api_allow_update' => false, 'uri' => 'http://localhost/bars/1/foos', 'output' => null, 'input' => null, 'iri_only' => false]; $this->assertEquals($expected, $this->builder->createFromRequest($request, false)); + + $request = Request::create('/foowithpatch/1', 'PATCH'); + $request->attributes->replace(['_api_resource_class' => 'FooWithPatch', '_api_item_operation_name' => 'patch', '_api_format' => 'json', '_api_mime_type' => 'application/json']); + $expected = ['item_operation_name' => 'patch', 'resource_class' => 'FooWithPatch', 'request_uri' => '/foowithpatch/1', 'operation_type' => 'item', 'api_allow_update' => true, 'uri' => 'http://localhost/foowithpatch/1', 'output' => null, 'input' => null, 'deep_object_to_populate' => true, 'skip_null_values' => true, 'iri_only' => false]; + $this->assertEquals($expected, $this->builder->createFromRequest($request, false)); } public function testThrowExceptionOnInvalidRequest() From cb7cf2bded5f97d3556652e8ce99a3f3cea3fc50 Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 30 Nov 2020 09:37:39 +0100 Subject: [PATCH 160/160] review --- features/main/patch.feature | 1 - src/Serializer/SerializerContextBuilder.php | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/features/main/patch.feature b/features/main/patch.feature index 28fce116892..eeddbc5a1e0 100644 --- a/features/main/patch.feature +++ b/features/main/patch.feature @@ -41,7 +41,6 @@ Feature: Sending PATCH requets } } """ - Then print last JSON response Then the response status code should be 200 And the response should be in JSON And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" diff --git a/src/Serializer/SerializerContextBuilder.php b/src/Serializer/SerializerContextBuilder.php index 0fdc2f8fdbf..d280b751a14 100644 --- a/src/Serializer/SerializerContextBuilder.php +++ b/src/Serializer/SerializerContextBuilder.php @@ -66,7 +66,7 @@ public function createFromRequest(Request $request, bool $normalization, array $ $context['api_allow_update'] = \in_array($method = $request->getMethod(), ['PUT', 'PATCH'], true); if ($context['api_allow_update'] && 'PATCH' === $method) { - $context[AbstractItemNormalizer::DEEP_OBJECT_TO_POPULATE] = $context[AbstractItemNormalizer::DEEP_OBJECT_TO_POPULATE] ?? true; + $context['deep_object_to_populate'] = $context['deep_object_to_populate'] ?? true; } } @@ -108,7 +108,7 @@ public function createFromRequest(Request $request, bool $normalization, array $ // TODO: We should always use `skip_null_values` but changing this would be a BC break, for now use it only when `merge-patch+json` is activated on a Resource foreach ($resourceMetadata->getItemOperations() as $operation) { if ('PATCH' === ($operation['method'] ?? '') && \in_array('application/merge-patch+json', $operation['input_formats']['json'] ?? [], true)) { - $context[AbstractItemNormalizer::SKIP_NULL_VALUES] = true; + $context['skip_null_values'] = true; break; }