diff --git a/features/bootstrap/FeatureContext.php b/features/bootstrap/FeatureContext.php index faae1e6f374..655d3b90f88 100644 --- a/features/bootstrap/FeatureContext.php +++ b/features/bootstrap/FeatureContext.php @@ -11,14 +11,18 @@ declare(strict_types=1); +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Answer; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\CompositeItem; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\CompositeLabel; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\CompositeRelation; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Container; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyCar; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyCarColor; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyFriend; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\FileConfigDummy; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Node; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Question; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\RelatedDummy; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\RelatedToDummyFriend; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\RelationEmbedder; @@ -418,6 +422,42 @@ public function thereIsARelatedDummyWithFriends($nb) $relatedDummy2 = new RelatedDummy(); $relatedDummy2->setName('RelatedDummy without friends'); $this->manager->persist($relatedDummy2); + $this->manager->flush(); + } + + /** + * @Given there is an answer :answer to the question :question + */ + public function thereIsAnAnswerToTheQuestion($a, $q) + { + $answer = new Answer(); + $answer->setContent($a); + + $question = new Question(); + $question->setContent($q); + + $question->setAnswer($answer); + + $this->manager->persist($answer); + $this->manager->persist($question); + $this->manager->flush(); + } + + /** + * @Given there are :nb nodes in a container :uuid + */ + public function thereAreNodesInAContainer($nb, $uuid) + { + $container = new Container(); + $container->setId($uuid); + $this->manager->persist($container); + + for ($i = 0; $i < $nb; ++$i) { + $node = new Node(); + $node->setContainer($container); + $node->setSerial($i); + $this->manager->persist($node); + } $this->manager->flush(); } diff --git a/features/main/subresource.feature b/features/main/subresource.feature new file mode 100644 index 00000000000..91ef169adf0 --- /dev/null +++ b/features/main/subresource.feature @@ -0,0 +1,224 @@ +Feature: Subresource support + In order to use a hypermedia API + As a client software developer + I need to be able to retrieve embedded resources only as Subresources + + @createSchema + Scenario: Get subresource one to one relation + Given there is an answer "42" to the question "What's the answer to the Ultimate Question of Life, the Universe and Everything?" + When I send a "GET" request to "/questions/1/answer" + And the response status code should be 200 + And the response should be in JSON + And the JSON should be equal to: + """ + { + "@context": "/contexts/Answer", + "@id": "/answers/1", + "@type": "Answer", + "id": 1, + "content": "42", + "question": "/questions/1" + } + """ + + Scenario: Create a third level + When I add "Content-Type" header equal to "application/ld+json" + And I send a "POST" request to "/third_levels" with body: + """ + {"level": 3} + """ + 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/ThirdLevel", + "@id": "/third_levels/1", + "@type": "ThirdLevel", + "id": 1, + "level": 3, + "test": true + } + """ + + Scenario: Create a named related dummy + When I add "Content-Type" header equal to "application/ld+json" + And I send a "POST" request to "/related_dummies" with body: + """ + {"name": "Hello", "thirdLevel": "/third_levels/1"} + """ + 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" + + Scenario: Create an unnamed related dummy + When I add "Content-Type" header equal to "application/ld+json" + And I send a "POST" request to "/related_dummies" with body: + """ + {"thirdLevel": "/third_levels/1"} + """ + 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" + + Scenario: Create a dummy with relations + When I add "Content-Type" header equal to "application/ld+json" + And I send a "POST" request to "/dummies" with body: + """ + { + "name": "Dummy with relations", + "relatedDummy": "http://example.com/related_dummies/1", + "relatedDummies": [ + "/related_dummies/1", + "/related_dummies/2" + ], + "name_converted": null + } + """ + 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" + + Scenario: Get the subresource relation collection + When I send a "GET" request to "/dummies/1/related_dummies" + And 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/RelatedDummy", + "@id": "/dummies/1/related_dummies", + "@type": "hydra:Collection", + "hydra:member": [ + { + "@id": "/related_dummies/1", + "@type": "https://schema.org/Product", + "id": 1, + "name": "Hello", + "symfony": "symfony", + "dummyDate": null, + "thirdLevel": "/third_levels/1", + "relatedToDummyFriend": [], + "dummyBoolean": null, + "age": null + }, + { + "@id": "/related_dummies/2", + "@type": "https://schema.org/Product", + "id": 2, + "name": null, + "symfony": "symfony", + "dummyDate": null, + "thirdLevel": "/third_levels/1", + "relatedToDummyFriend": [], + "dummyBoolean": null, + "age": null + } + ], + "hydra:totalItems": 2, + "hydra:search": { + "@type": "hydra:IriTemplate", + "hydra:template": "/dummies/1/related_dummies{?relatedToDummyFriend.dummyFriend,relatedToDummyFriend.dummyFriend[],name}", + "hydra:variableRepresentation": "BasicRepresentation", + "hydra:mapping": [ + { + "@type": "IriTemplateMapping", + "variable": "relatedToDummyFriend.dummyFriend", + "property": "relatedToDummyFriend.dummyFriend", + "required": false + }, + { + "@type": "IriTemplateMapping", + "variable": "relatedToDummyFriend.dummyFriend[]", + "property": "relatedToDummyFriend.dummyFriend", + "required": false + }, + { + "@type": "IriTemplateMapping", + "variable": "name", + "property": "name", + "required": false + } + ] + } + } + """ + + Scenario: Get filtered embedded relation collection + When I send a "GET" request to "/dummies/1/related_dummies?name=Hello" + And 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/RelatedDummy", + "@id": "/dummies/1/related_dummies", + "@type": "hydra:Collection", + "hydra:member": [ + { + "@id": "/related_dummies/1", + "@type": "https://schema.org/Product", + "id": 1, + "name": "Hello", + "symfony": "symfony", + "dummyDate": null, + "thirdLevel": "/third_levels/1", + "relatedToDummyFriend": [], + "dummyBoolean": null, + "age": null + } + ], + "hydra:totalItems": 1, + "hydra:view": { + "@id": "/dummies/1/related_dummies?name=Hello", + "@type": "hydra:PartialCollectionView" + }, + "hydra:search": { + "@type": "hydra:IriTemplate", + "hydra:template": "/dummies/1/related_dummies{?relatedToDummyFriend.dummyFriend,relatedToDummyFriend.dummyFriend[],name}", + "hydra:variableRepresentation": "BasicRepresentation", + "hydra:mapping": [ + { + "@type": "IriTemplateMapping", + "variable": "relatedToDummyFriend.dummyFriend", + "property": "relatedToDummyFriend.dummyFriend", + "required": false + }, + { + "@type": "IriTemplateMapping", + "variable": "relatedToDummyFriend.dummyFriend[]", + "property": "relatedToDummyFriend.dummyFriend", + "required": false + }, + { + "@type": "IriTemplateMapping", + "variable": "name", + "property": "name", + "required": false + } + ] + } + } + """ + + @dropSchema + Scenario: Get the embedded relation collection + When I send a "GET" request to "/dummies/1/related_dummies/1/third_level" + And 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/ThirdLevel", + "@id": "/third_levels/1", + "@type": "ThirdLevel", + "id": 1, + "level": 3, + "test": true + } + """ + diff --git a/phpstan.neon b/phpstan.neon index 2362d1b405c..c98953cc9c4 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -15,3 +15,4 @@ parameters: - '#Parameter \#1 \$rootNode of method ApiPlatform\\Core\\Bridge\\Symfony\\Bundle\\DependencyInjection\\Configuration::[a-zA-Z0-9]+\(\) expects Symfony\\Component\\Config\\Definition\\Builder\\ArrayNodeDefinition, Symfony\\Component\\Config\\Definition\\Builder\\ArrayNodeDefinition|Symfony\\Component\\Config\\Definition\\Builder\\NodeDefinition given#' - '#Parameter \#1 \$source of static method ApiPlatform\\Core\\Util\\RequestParser::parseRequestParams\(\) expects string, string\|resource given#' - '#Call to an undefined method Doctrine\\Common\\Persistence\\ObjectManager::getConnection\(\)#' + - '#Method ApiPlatform\\Core\\Api\\OperationTypeDeprecationHelper::getOperationType\(\) should return string but returns string|bool|null#' diff --git a/src/Annotation/ApiProperty.php b/src/Annotation/ApiProperty.php index 48de8d3f5b1..39b498a363d 100644 --- a/src/Annotation/ApiProperty.php +++ b/src/Annotation/ApiProperty.php @@ -67,4 +67,9 @@ final class ApiProperty * @var array */ public $attributes = []; + + /** + * @var bool + */ + public $subresource; } diff --git a/src/Api/IriConverterInterface.php b/src/Api/IriConverterInterface.php index b8a26159c47..2d7d59df66f 100644 --- a/src/Api/IriConverterInterface.php +++ b/src/Api/IriConverterInterface.php @@ -59,4 +59,30 @@ public function getIriFromItem($item, int $referenceType = UrlGeneratorInterface * @return string */ public function getIriFromResourceClass(string $resourceClass, int $referenceType = UrlGeneratorInterface::ABS_PATH): string; + + /** + * Gets the item IRI associated with the given resource. + * + * @param string $resourceClass + * @param array $identifiers + * @param int $referenceType + * + * @throws InvalidArgumentException + * + * @return string + */ + public function getItemIriFromResourceClass(string $resourceClass, array $identifiers, int $referenceType = UrlGeneratorInterface::ABS_PATH): string; + + /** + * Gets the IRI associated with the given resource subresource. + * + * @param string $resourceClass + * @param array $identifiers + * @param int $referenceType + * + * @throws InvalidArgumentException + * + * @return string + */ + public function getSubresourceIriFromResourceClass(string $resourceClass, array $identifiers, int $referenceType = UrlGeneratorInterface::ABS_PATH): string; } diff --git a/src/Api/OperationType.php b/src/Api/OperationType.php new file mode 100644 index 00000000000..32e58a5f8a5 --- /dev/null +++ b/src/Api/OperationType.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace ApiPlatform\Core\Api; + +final class OperationType +{ + const ITEM = 'item'; + const COLLECTION = 'collection'; + const SUBRESOURCE = 'subresource'; + const TYPES = [self::ITEM, self::COLLECTION, self::SUBRESOURCE]; +} diff --git a/src/Api/OperationTypeDeprecationHelper.php b/src/Api/OperationTypeDeprecationHelper.php new file mode 100644 index 00000000000..ccd13cd61a1 --- /dev/null +++ b/src/Api/OperationTypeDeprecationHelper.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace ApiPlatform\Core\Api; + +/** + * Class OperationTypeDeprecationHelper + * Before API Platform 2.1, the operation type was one of: + * - "collection" (true) + * - "item" (false). + * + * Because we introduced a third type in API Platform 2.1, we're using a string with OperationType constants: + * - OperationType::ITEM + * - OperationType::COLLECTION + * - OperationType::SUBCOLLECTION + * + * @internal + */ +final class OperationTypeDeprecationHelper +{ + /** + * @param string|bool $operationType + * + * @return string + */ + public static function getOperationType($operationType): string + { + if (is_bool($operationType)) { + @trigger_error('Using a boolean for the Operation Type is deprecrated since API Platform 2.1 and will not be possible anymore in API Platform 3', E_USER_DEPRECATED); + + $operationType = $operationType === true ? OperationType::COLLECTION : OperationType::ITEM; + } + + return $operationType; + } +} diff --git a/src/Bridge/Doctrine/Orm/SubresourceDataProvider.php b/src/Bridge/Doctrine/Orm/SubresourceDataProvider.php new file mode 100644 index 00000000000..19594b3978c --- /dev/null +++ b/src/Bridge/Doctrine/Orm/SubresourceDataProvider.php @@ -0,0 +1,191 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace ApiPlatform\Core\Bridge\Doctrine\Orm; + +use ApiPlatform\Core\Bridge\Doctrine\Orm\Extension\QueryCollectionExtensionInterface; +use ApiPlatform\Core\Bridge\Doctrine\Orm\Extension\QueryItemExtensionInterface; +use ApiPlatform\Core\Bridge\Doctrine\Orm\Extension\QueryResultCollectionExtensionInterface; +use ApiPlatform\Core\Bridge\Doctrine\Orm\Extension\QueryResultItemExtensionInterface; +use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\IdentifierManagerTrait; +use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGenerator; +use ApiPlatform\Core\DataProvider\SubresourceDataProviderInterface; +use ApiPlatform\Core\Exception\ResourceClassNotSupportedException; +use ApiPlatform\Core\Exception\RuntimeException; +use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; +use Doctrine\Common\Persistence\ManagerRegistry; +use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\Mapping\ClassMetadataInfo; + +/** + * Subresource data provider for the Doctrine ORM. + * + * @author Antoine Bluchet + */ +final class SubresourceDataProvider implements SubresourceDataProviderInterface +{ + use IdentifierManagerTrait; + + private $managerRegistry; + private $collectionExtensions; + private $itemExtensions; + + /** + * @param ManagerRegistry $managerRegistry + * @param PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory + * @param PropertyMetadataFactoryInterface $propertyMetadataFactory + * @param QueryCollectionExtensionInterface[] $collectionExtensions + * @param QueryItemExtensionInterface[] $itemExtensions + */ + public function __construct(ManagerRegistry $managerRegistry, PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, array $collectionExtensions = [], array $itemExtensions = []) + { + $this->managerRegistry = $managerRegistry; + $this->propertyNameCollectionFactory = $propertyNameCollectionFactory; + $this->propertyMetadataFactory = $propertyMetadataFactory; + $this->collectionExtensions = $collectionExtensions; + $this->itemExtensions = $itemExtensions; + } + + /** + * {@inheritdoc} + * + * @throws RuntimeException + */ + public function getSubresource(string $resourceClass, array $identifiers, array $context, string $operationName = null) + { + $manager = $this->managerRegistry->getManagerForClass($resourceClass); + if (null === $manager) { + throw new ResourceClassNotSupportedException(); + } + + $repository = $manager->getRepository($resourceClass); + if (!method_exists($repository, 'createQueryBuilder')) { + throw new RuntimeException('The repository class must have a "createQueryBuilder" method.'); + } + + if (!isset($context['identifiers']) || !isset($context['property'])) { + throw new ResourceClassNotSupportedException('The given resource class is not a subresource.'); + } + + $originAlias = 'o'; + $queryBuilder = $repository->createQueryBuilder($originAlias); + $queryNameGenerator = new QueryNameGenerator(); + $previousQueryBuilder = null; + $previousAlias = null; + + $num = count($context['identifiers']); + + while ($num--) { + list($identifier, $identifierResourceClass) = $context['identifiers'][$num]; + $previousAssociationProperty = $context['identifiers'][$num + 1][0] ?? $context['property']; + + $manager = $this->managerRegistry->getManagerForClass($identifierResourceClass); + + if (!$manager instanceof EntityManagerInterface) { + throw new RuntimeException("The manager for $identifierResourceClass must be an EntityManager."); + } + + $classMetadata = $manager->getClassMetadata($identifierResourceClass); + + if (!$classMetadata instanceof ClassMetadataInfo) { + throw new RuntimeException("The class metadata for $identifierResourceClass must be an instance of ClassMetadataInfo."); + } + + $qb = $manager->createQueryBuilder(); + $alias = $queryNameGenerator->generateJoinAlias($identifier); + $relationType = $classMetadata->getAssociationMapping($previousAssociationProperty)['type']; + $normalizedIdentifiers = $this->normalizeIdentifiers($identifiers[$identifier], $manager, $identifierResourceClass); + + switch ($relationType) { + //MANY_TO_MANY relations need an explicit join so that the identifier part can be retrieved + case ClassMetadataInfo::MANY_TO_MANY: + $joinAlias = $queryNameGenerator->generateJoinAlias($previousAssociationProperty); + + $qb->select($joinAlias) + ->from($identifierResourceClass, $alias) + ->innerJoin("$alias.$previousAssociationProperty", $joinAlias); + + break; + case ClassMetadataInfo::ONE_TO_MANY: + $mappedBy = $classMetadata->getAssociationMapping($previousAssociationProperty)['mappedBy']; + + // first pass, o.property instead of alias.property + if (null === $previousQueryBuilder) { + $originAlias = "$originAlias.$mappedBy"; + } else { + $previousAlias = "$previousAlias.$mappedBy"; + } + + $qb->select($alias) + ->from($identifierResourceClass, $alias); + break; + default: + $qb->select("IDENTITY($alias.$previousAssociationProperty)") + ->from($identifierResourceClass, $alias); + } + + // Add where clause for identifiers + foreach ($normalizedIdentifiers as $key => $value) { + $placeholder = $queryNameGenerator->generateParameterName($key); + $qb->andWhere("$alias.$key = :$placeholder"); + $queryBuilder->setParameter($placeholder, $value); + } + + // recurse queries + if (null === $previousQueryBuilder) { + $previousQueryBuilder = $qb; + } else { + $previousQueryBuilder->andWhere($qb->expr()->in($previousAlias, $qb->getDQL())); + } + + $previousAlias = $alias; + } + + /* + * The following translate to this pseudo-dql: + * + * SELECT thirdLevel WHERE thirdLevel IN ( + * SELECT thirdLevel FROM relatedDummies WHERE relatedDummies = ? AND relatedDummies IN ( + * SELECT relatedDummies FROM Dummy WHERE Dummy = ? + * ) + * ) + * + * By using subqueries, we're forcing the SQL execution plan to go through indexes on doctrine identifiers. + */ + $queryBuilder->where( + $queryBuilder->expr()->in($originAlias, $previousQueryBuilder->getDQL()) + ); + + if (true === $context['collection']) { + foreach ($this->collectionExtensions as $extension) { + $extension->applyToCollection($queryBuilder, $queryNameGenerator, $resourceClass, $operationName); + + if ($extension instanceof QueryResultCollectionExtensionInterface && $extension->supportsResult($resourceClass, $operationName)) { + return $extension->getResult($queryBuilder); + } + } + } else { + foreach ($this->itemExtensions as $extension) { + $extension->applyToItem($queryBuilder, $queryNameGenerator, $resourceClass, $identifiers, $operationName, $context); + + if ($extension instanceof QueryResultItemExtensionInterface && $extension->supportsResult($resourceClass, $operationName)) { + return $extension->getResult($queryBuilder); + } + } + } + + $query = $queryBuilder->getQuery(); + + return $context['collection'] ? $query->getResult() : $query->getOneOrNullResult(); + } +} diff --git a/src/Bridge/Symfony/Bundle/DependencyInjection/Compiler/DataProviderPass.php b/src/Bridge/Symfony/Bundle/DependencyInjection/Compiler/DataProviderPass.php index b2878ef0aa0..f3033718a90 100644 --- a/src/Bridge/Symfony/Bundle/DependencyInjection/Compiler/DataProviderPass.php +++ b/src/Bridge/Symfony/Bundle/DependencyInjection/Compiler/DataProviderPass.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Core\Bridge\Symfony\Bundle\DependencyInjection\Compiler; +use ApiPlatform\Core\Api\OperationType; use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Reference; @@ -31,8 +32,9 @@ final class DataProviderPass implements CompilerPassInterface */ public function process(ContainerBuilder $container) { - $this->registerDataProviders($container, 'collection'); - $this->registerDataProviders($container, 'item'); + foreach (OperationType::TYPES as $type) { + $this->registerDataProviders($container, $type); + } } /** diff --git a/src/Bridge/Symfony/Bundle/DependencyInjection/Compiler/DoctrineQueryExtensionPass.php b/src/Bridge/Symfony/Bundle/DependencyInjection/Compiler/DoctrineQueryExtensionPass.php index 813a2793592..5d18bab3cb9 100644 --- a/src/Bridge/Symfony/Bundle/DependencyInjection/Compiler/DoctrineQueryExtensionPass.php +++ b/src/Bridge/Symfony/Bundle/DependencyInjection/Compiler/DoctrineQueryExtensionPass.php @@ -39,9 +39,15 @@ public function process(ContainerBuilder $container) $collectionDataProviderDefinition = $container->getDefinition('api_platform.doctrine.orm.collection_data_provider'); $itemDataProviderDefinition = $container->getDefinition('api_platform.doctrine.orm.item_data_provider'); + $subresourceDataProviderDefinition = $container->getDefinition('api_platform.doctrine.orm.subresource_data_provider'); - $collectionDataProviderDefinition->replaceArgument(1, $this->findSortedServices($container, 'api_platform.doctrine.orm.query_extension.collection')); - $itemDataProviderDefinition->replaceArgument(3, $this->findSortedServices($container, 'api_platform.doctrine.orm.query_extension.item')); + $collectionExtensions = $this->findSortedServices($container, 'api_platform.doctrine.orm.query_extension.collection'); + $itemExtensions = $this->findSortedServices($container, 'api_platform.doctrine.orm.query_extension.item'); + + $collectionDataProviderDefinition->replaceArgument(1, $collectionExtensions); + $itemDataProviderDefinition->replaceArgument(3, $itemExtensions); + $subresourceDataProviderDefinition->replaceArgument(3, $collectionExtensions); + $subresourceDataProviderDefinition->replaceArgument(4, $itemExtensions); } /** diff --git a/src/Bridge/Symfony/Bundle/Resources/config/api.xml b/src/Bridge/Symfony/Bundle/Resources/config/api.xml index 6ba8243a4d7..938ee90901b 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/api.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/api.xml @@ -36,6 +36,8 @@ %api_platform.formats% %api_platform.resource_class_directories% + + @@ -101,6 +103,7 @@ + @@ -147,6 +150,7 @@ + diff --git a/src/Bridge/Symfony/Bundle/Resources/config/data_provider.xml b/src/Bridge/Symfony/Bundle/Resources/config/data_provider.xml index 6cec2537ad2..98f0552862e 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/data_provider.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/data_provider.xml @@ -9,6 +9,8 @@ + + diff --git a/src/Bridge/Symfony/Bundle/Resources/config/doctrine_orm.xml b/src/Bridge/Symfony/Bundle/Resources/config/doctrine_orm.xml index a7b6315c1d0..41e178f1ef0 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/doctrine_orm.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/doctrine_orm.xml @@ -21,6 +21,14 @@ + + + + + + + + @@ -29,6 +37,10 @@ + + + + diff --git a/src/Bridge/Symfony/Routing/ApiLoader.php b/src/Bridge/Symfony/Routing/ApiLoader.php index 24cb18cc4ea..fa50d8547e2 100644 --- a/src/Bridge/Symfony/Routing/ApiLoader.php +++ b/src/Bridge/Symfony/Routing/ApiLoader.php @@ -13,8 +13,11 @@ namespace ApiPlatform\Core\Bridge\Symfony\Routing; +use ApiPlatform\Core\Api\OperationType; use ApiPlatform\Core\Exception\InvalidResourceException; use ApiPlatform\Core\Exception\RuntimeException; +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\PathResolver\OperationPathResolverInterface; @@ -37,8 +40,11 @@ final class ApiLoader extends Loader { const ROUTE_NAME_PREFIX = 'api_'; const DEFAULT_ACTION_PATTERN = 'api_platform.action.'; + const SUBRESOURCE_SUFFIX = '_get_subresource'; private $fileLoader; + private $propertyNameCollectionFactory; + private $propertyMetadataFactory; private $resourceNameCollectionFactory; private $resourceMetadataFactory; private $operationPathResolver; @@ -46,7 +52,7 @@ final class ApiLoader extends Loader private $formats; private $resourceClassDirectories; - public function __construct(KernelInterface $kernel, ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, ResourceMetadataFactoryInterface $resourceMetadataFactory, OperationPathResolverInterface $operationPathResolver, ContainerInterface $container, array $formats, array $resourceClassDirectories = []) + public function __construct(KernelInterface $kernel, ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, ResourceMetadataFactoryInterface $resourceMetadataFactory, OperationPathResolverInterface $operationPathResolver, ContainerInterface $container, array $formats, array $resourceClassDirectories = [], PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory = null, PropertyMetadataFactoryInterface $propertyMetadataFactory = null) { $this->fileLoader = new XmlFileLoader(new FileLocator($kernel->locateResource('@ApiPlatformBundle/Resources/config/routing'))); $this->resourceNameCollectionFactory = $resourceNameCollectionFactory; @@ -55,6 +61,8 @@ public function __construct(KernelInterface $kernel, ResourceNameCollectionFacto $this->container = $container; $this->formats = $formats; $this->resourceClassDirectories = $resourceClassDirectories; + $this->propertyNameCollectionFactory = $propertyNameCollectionFactory; + $this->propertyMetadataFactory = $propertyMetadataFactory; } /** @@ -79,20 +87,112 @@ public function load($data, $type = null): RouteCollection if (null !== $collectionOperations = $resourceMetadata->getCollectionOperations()) { foreach ($collectionOperations as $operationName => $operation) { - $this->addRoute($routeCollection, $resourceClass, $operationName, $operation, $resourceShortName, true); + $this->addRoute($routeCollection, $resourceClass, $operationName, $operation, $resourceShortName, OperationType::COLLECTION); } } if (null !== $itemOperations = $resourceMetadata->getItemOperations()) { foreach ($itemOperations as $operationName => $operation) { - $this->addRoute($routeCollection, $resourceClass, $operationName, $operation, $resourceShortName, false); + $this->addRoute($routeCollection, $resourceClass, $operationName, $operation, $resourceShortName, OperationType::ITEM); } } + + $this->computeSubresourceOperations($routeCollection, $resourceClass); } return $routeCollection; } + /** + * Transforms a given string to a tableized, pluralized string. + * + * @param string $name usually a ResourceMetadata shortname + * + * @return string A string that is a part of the route name + */ + private function routeNameResolver(string $name, bool $pluralize = true): string + { + $name = Inflector::tableize($name); + + return $pluralize ? Inflector::pluralize($name) : $name; + } + + /** + * Handles subresource operations recursively and declare their corresponding routes. + * + * @param RouteCollection $routeCollection + * @param string $resourceClass + * @param string $rootResourceClass null on the first iteration, it then keeps track of the origin resource class + * @param array $parentOperation the previous call operation + */ + private function computeSubresourceOperations(RouteCollection $routeCollection, string $resourceClass, string $rootResourceClass = null, array $parentOperation = null) + { + if (null === $this->propertyNameCollectionFactory || null === $this->propertyMetadataFactory) { + return; + } + + if (null === $rootResourceClass) { + $rootResourceClass = $resourceClass; + } + + foreach ($this->propertyNameCollectionFactory->create($resourceClass) as $property) { + $propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $property); + + if (null === $propertyMetadata->hasSubresource()) { + continue; + } + + $isCollection = $propertyMetadata->getType()->isCollection(); + $subresource = $isCollection ? $propertyMetadata->getType()->getCollectionValueType()->getClassName() : $propertyMetadata->getType()->getClassName(); + + $propertyName = $this->routeNameResolver($property, $isCollection); + + $operation = [ + 'property' => $property, + 'collection' => $isCollection, + ]; + + if (null === $parentOperation) { + $rootResourceMetadata = $this->resourceMetadataFactory->create($rootResourceClass); + $rootShortname = $rootResourceMetadata->getShortName(); + $resourceRouteName = $this->routeNameResolver($rootShortname); + + $operation['identifiers'] = [['id', $rootResourceClass]]; + $operation['route_name'] = sprintf('%s%s_%s%s', self::ROUTE_NAME_PREFIX, $resourceRouteName, $propertyName, self::SUBRESOURCE_SUFFIX); + $operation['path'] = $this->operationPathResolver->resolveOperationPath($rootShortname, $operation, OperationType::SUBRESOURCE); + } else { + $operation['identifiers'] = $parentOperation['identifiers']; + $operation['identifiers'][] = [$parentOperation['property'], $resourceClass]; + $operation['route_name'] = str_replace(self::SUBRESOURCE_SUFFIX, "_$propertyName".self::SUBRESOURCE_SUFFIX, $parentOperation['route_name']); + $operation['path'] = $this->operationPathResolver->resolveOperationPath($parentOperation['path'], $operation, OperationType::SUBRESOURCE); + } + + $route = new Route( + $operation['path'], + [ + '_controller' => self::DEFAULT_ACTION_PATTERN.'get_subresource', + '_format' => null, + '_api_resource_class' => $subresource, + '_api_subresource_operation_name' => 'get', + '_api_subresource_context' => [ + 'property' => $operation['property'], + 'identifiers' => $operation['identifiers'], + 'collection' => $isCollection, + ], + ], + [], + [], + '', + [], + ['GET'] + ); + + $routeCollection->add($operation['route_name'], $route); + + $this->computeSubresourceOperations($routeCollection, $subresource, $rootResourceClass, $operation); + } + } + /** * {@inheritdoc} */ @@ -123,11 +223,11 @@ private function loadExternalFiles(RouteCollection $routeCollection) * @param string $operationName * @param array $operation * @param string $resourceShortName - * @param bool $collection + * @param string $operationType * * @throws RuntimeException */ - private function addRoute(RouteCollection $routeCollection, string $resourceClass, string $operationName, array $operation, string $resourceShortName, bool $collection) + private function addRoute(RouteCollection $routeCollection, string $resourceClass, string $operationName, array $operation, string $resourceShortName, string $operationType) { if (isset($operation['route_name'])) { return; @@ -138,24 +238,23 @@ private function addRoute(RouteCollection $routeCollection, string $resourceClas } $controller = $operation['controller'] ?? null; - $collectionType = $collection ? 'collection' : 'item'; - $actionName = sprintf('%s_%s', strtolower($operation['method']), $collectionType); + $actionName = sprintf('%s_%s', strtolower($operation['method']), $operationType); if (null === $controller) { $controller = self::DEFAULT_ACTION_PATTERN.$actionName; if (!$this->container->has($controller)) { - throw new RuntimeException(sprintf('There is no builtin action for the %s %s operation. You need to define the controller yourself.', $collectionType, $operation['method'])); + throw new RuntimeException(sprintf('There is no builtin action for the %s %s operation. You need to define the controller yourself.', $operationType, $operation['method'])); } } if ($operationName !== strtolower($operation['method'])) { - $actionName = sprintf('%s_%s', $operationName, $collection ? 'collection' : 'item'); + $actionName = sprintf('%s_%s', $operationName, $operationType); } - $path = $this->operationPathResolver->resolveOperationPath($resourceShortName, $operation, $collection); + $path = $this->operationPathResolver->resolveOperationPath($resourceShortName, $operation, $operationType); - $resourceRouteName = Inflector::pluralize(Inflector::tableize($resourceShortName)); + $resourceRouteName = $this->routeNameResolver($resourceShortName); $routeName = sprintf('%s%s_%s', self::ROUTE_NAME_PREFIX, $resourceRouteName, $actionName); $route = new Route( @@ -164,7 +263,7 @@ private function addRoute(RouteCollection $routeCollection, string $resourceClas '_controller' => $controller, '_format' => null, '_api_resource_class' => $resourceClass, - sprintf('_api_%s_operation_name', $collection ? 'collection' : 'item') => $operationName, + sprintf('_api_%s_operation_name', $operationType) => $operationName, ], [], [], diff --git a/src/Bridge/Symfony/Routing/CachedRouteNameResolver.php b/src/Bridge/Symfony/Routing/CachedRouteNameResolver.php index f2e4b88af8c..da31bf989c7 100644 --- a/src/Bridge/Symfony/Routing/CachedRouteNameResolver.php +++ b/src/Bridge/Symfony/Routing/CachedRouteNameResolver.php @@ -37,9 +37,9 @@ public function __construct(CacheItemPoolInterface $cacheItemPool, RouteNameReso /** * {@inheritdoc} */ - public function getRouteName(string $resourceClass, bool $collection): string + public function getRouteName(string $resourceClass, $operationType): string { - $cacheKey = self::CACHE_KEY_PREFIX.md5(serialize([$resourceClass, $collection])); + $cacheKey = self::CACHE_KEY_PREFIX.md5(serialize([$resourceClass, $operationType])); try { $cacheItem = $this->cacheItemPool->getItem($cacheKey); @@ -51,7 +51,7 @@ public function getRouteName(string $resourceClass, bool $collection): string // do nothing } - $routeName = $this->decorated->getRouteName($resourceClass, $collection); + $routeName = $this->decorated->getRouteName($resourceClass, $operationType); if (!isset($cacheItem)) { return $routeName; diff --git a/src/Bridge/Symfony/Routing/IriConverter.php b/src/Bridge/Symfony/Routing/IriConverter.php index 82a48f39a58..757d17efa39 100644 --- a/src/Bridge/Symfony/Routing/IriConverter.php +++ b/src/Bridge/Symfony/Routing/IriConverter.php @@ -16,6 +16,7 @@ use ApiPlatform\Core\Api\IdentifiersExtractor; use ApiPlatform\Core\Api\IdentifiersExtractorInterface; use ApiPlatform\Core\Api\IriConverterInterface; +use ApiPlatform\Core\Api\OperationType; use ApiPlatform\Core\Api\UrlGeneratorInterface; use ApiPlatform\Core\DataProvider\ItemDataProviderInterface; use ApiPlatform\Core\Exception\InvalidArgumentException; @@ -103,7 +104,31 @@ public function getIriFromItem($item, int $referenceType = UrlGeneratorInterface public function getIriFromResourceClass(string $resourceClass, int $referenceType = UrlGeneratorInterface::ABS_PATH): string { try { - return $this->router->generate($this->routeNameResolver->getRouteName($resourceClass, true), [], $referenceType); + return $this->router->generate($this->routeNameResolver->getRouteName($resourceClass, OperationType::COLLECTION), [], $referenceType); + } catch (RoutingExceptionInterface $e) { + throw new InvalidArgumentException(sprintf('Unable to generate an IRI for "%s".', $resourceClass), $e->getCode(), $e); + } + } + + /** + * {@inheritdoc} + */ + public function getItemIriFromResourceClass(string $resourceClass, array $identifiers, int $referenceType = UrlGeneratorInterface::ABS_PATH): string + { + try { + return $this->router->generate($this->routeNameResolver->getRouteName($resourceClass, OperationType::ITEM), $identifiers, $referenceType); + } catch (RoutingExceptionInterface $e) { + throw new InvalidArgumentException(sprintf('Unable to generate an IRI for "%s".', $resourceClass), $e->getCode(), $e); + } + } + + /** + * {@inheritdoc} + */ + public function getSubresourceIriFromResourceClass(string $resourceClass, array $identifiers, int $referenceType = UrlGeneratorInterface::ABS_PATH): string + { + try { + return $this->router->generate($this->routeNameResolver->getRouteName($resourceClass, OperationType::SUBRESOURCE), $identifiers, $referenceType); } catch (RoutingExceptionInterface $e) { throw new InvalidArgumentException(sprintf('Unable to generate an IRI for "%s".', $resourceClass), $e->getCode(), $e); } diff --git a/src/Bridge/Symfony/Routing/OperationMethodResolver.php b/src/Bridge/Symfony/Routing/OperationMethodResolver.php index a5e99ac488f..be46bb537fe 100644 --- a/src/Bridge/Symfony/Routing/OperationMethodResolver.php +++ b/src/Bridge/Symfony/Routing/OperationMethodResolver.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Core\Bridge\Symfony\Routing; +use ApiPlatform\Core\Api\OperationType; use ApiPlatform\Core\Exception\RuntimeException; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; @@ -41,7 +42,7 @@ public function __construct(RouterInterface $router, ResourceMetadataFactoryInte */ public function getCollectionOperationMethod(string $resourceClass, string $operationName): string { - return $this->getOperationMethod($resourceClass, $operationName, true); + return $this->getOperationMethod($resourceClass, $operationName, OperationType::COLLECTION); } /** @@ -49,7 +50,7 @@ public function getCollectionOperationMethod(string $resourceClass, string $oper */ public function getItemOperationMethod(string $resourceClass, string $operationName): string { - return $this->getOperationMethod($resourceClass, $operationName, false); + return $this->getOperationMethod($resourceClass, $operationName, OperationType::ITEM); } /** @@ -57,7 +58,7 @@ public function getItemOperationMethod(string $resourceClass, string $operationN */ public function getCollectionOperationRoute(string $resourceClass, string $operationName): Route { - return $this->getOperationRoute($resourceClass, $operationName, true); + return $this->getOperationRoute($resourceClass, $operationName, OperationType::COLLECTION); } /** @@ -65,33 +66,33 @@ public function getCollectionOperationRoute(string $resourceClass, string $opera */ public function getItemOperationRoute(string $resourceClass, string $operationName): Route { - return $this->getOperationRoute($resourceClass, $operationName, false); + return $this->getOperationRoute($resourceClass, $operationName, OperationType::ITEM); } /** * @param string $resourceClass * @param string $operationName - * @param bool $collection + * @param string $operationType * * @throws RuntimeException * * @return string */ - private function getOperationMethod(string $resourceClass, string $operationName, bool $collection): string + private function getOperationMethod(string $resourceClass, string $operationName, string $operationType): string { $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass); - if ($collection) { - $method = $resourceMetadata->getCollectionOperationAttribute($operationName, 'method'); - } else { + if ($operationType === OperationType::ITEM) { $method = $resourceMetadata->getItemOperationAttribute($operationName, 'method'); + } else { + $method = $resourceMetadata->getCollectionOperationAttribute($operationName, 'method'); } if (null !== $method) { return $method; } - if (null === $routeName = $this->getRouteName($resourceMetadata, $operationName, $collection)) { + if (null === $routeName = $this->getRouteName($resourceMetadata, $operationName, $operationType)) { throw new RuntimeException(sprintf('Either a "route_name" or a "method" operation attribute must exist for the operation "%s" of the resource "%s".', $operationName, $resourceClass)); } @@ -110,20 +111,20 @@ private function getOperationMethod(string $resourceClass, string $operationName * * @param string $resourceClass * @param string $operationName - * @param bool $collection + * @param string $operationType * * @throws RuntimeException * * @return Route */ - private function getOperationRoute(string $resourceClass, string $operationName, bool $collection): Route + private function getOperationRoute(string $resourceClass, string $operationName, string $operationType): Route { - $routeName = $this->getRouteName($this->resourceMetadataFactory->create($resourceClass), $operationName, $collection); + $routeName = $this->getRouteName($this->resourceMetadataFactory->create($resourceClass), $operationName, $operationType); if (null !== $routeName) { return $this->getRoute($routeName); } - $operationNameKey = sprintf('_api_%s_operation_name', $collection ? 'collection' : 'item'); + $operationNameKey = sprintf('_api_%s_operation_name', $operationType); foreach ($this->router->getRouteCollection()->all() as $routeName => $route) { $currentResourceClass = $route->getDefault('_api_resource_class'); @@ -142,17 +143,17 @@ private function getOperationRoute(string $resourceClass, string $operationName, * * @param ResourceMetadata $resourceMetadata * @param string $operationName - * @param bool $collection + * @param string $operationType * * @return string|null */ - private function getRouteName(ResourceMetadata $resourceMetadata, string $operationName, bool $collection) + private function getRouteName(ResourceMetadata $resourceMetadata, string $operationName, string $operationType) { - if ($collection) { - return $resourceMetadata->getCollectionOperationAttribute($operationName, 'route_name'); + if ($operationType === OperationType::ITEM) { + return $resourceMetadata->getItemOperationAttribute($operationName, 'route_name'); } - return $resourceMetadata->getItemOperationAttribute($operationName, 'route_name'); + return $resourceMetadata->getCollectionOperationAttribute($operationName, 'route_name'); } /** diff --git a/src/Bridge/Symfony/Routing/RouteNameResolver.php b/src/Bridge/Symfony/Routing/RouteNameResolver.php index 32148d01339..2cf137fd515 100644 --- a/src/Bridge/Symfony/Routing/RouteNameResolver.php +++ b/src/Bridge/Symfony/Routing/RouteNameResolver.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Core\Bridge\Symfony\Routing; +use ApiPlatform\Core\Api\OperationTypeDeprecationHelper; use ApiPlatform\Core\Exception\InvalidArgumentException; use Symfony\Component\Routing\RouterInterface; @@ -33,9 +34,9 @@ public function __construct(RouterInterface $router) /** * {@inheritdoc} */ - public function getRouteName(string $resourceClass, bool $collection): string + public function getRouteName(string $resourceClass, $operationType): string { - $operationType = $collection ? 'collection' : 'item'; + $operationType = OperationTypeDeprecationHelper::getOperationType($operationType); foreach ($this->router->getRouteCollection()->all() as $routeName => $route) { $currentResourceClass = $route->getDefault('_api_resource_class'); diff --git a/src/Bridge/Symfony/Routing/RouteNameResolverInterface.php b/src/Bridge/Symfony/Routing/RouteNameResolverInterface.php index 573b7681763..efa5c52276d 100644 --- a/src/Bridge/Symfony/Routing/RouteNameResolverInterface.php +++ b/src/Bridge/Symfony/Routing/RouteNameResolverInterface.php @@ -25,12 +25,12 @@ interface RouteNameResolverInterface /** * Finds the route name for a resource. * - * @param string $resourceClass - * @param bool $collection + * @param string $resourceClass + * @param bool|string $operationType * * @throws InvalidArgumentException * * @return string */ - public function getRouteName(string $resourceClass, bool $collection): string; + public function getRouteName(string $resourceClass, $operationType): string; } diff --git a/src/Bridge/Symfony/Routing/RouterOperationPathResolver.php b/src/Bridge/Symfony/Routing/RouterOperationPathResolver.php index 6059e8df910..908eb658a00 100644 --- a/src/Bridge/Symfony/Routing/RouterOperationPathResolver.php +++ b/src/Bridge/Symfony/Routing/RouterOperationPathResolver.php @@ -38,10 +38,10 @@ public function __construct(RouterInterface $router, OperationPathResolverInterf * * @throws InvalidArgumentException */ - public function resolveOperationPath(string $resourceShortName, array $operation, bool $collection): string + public function resolveOperationPath(string $resourceShortName, array $operation, $operationType): string { if (!isset($operation['route_name'])) { - return $this->deferred->resolveOperationPath($resourceShortName, $operation, $collection); + return $this->deferred->resolveOperationPath($resourceShortName, $operation, $operationType); } $route = $this->router->getRouteCollection()->get($operation['route_name']); diff --git a/src/DataProvider/ChainSubresourceDataProvider.php b/src/DataProvider/ChainSubresourceDataProvider.php new file mode 100644 index 00000000000..f3af72eb6d9 --- /dev/null +++ b/src/DataProvider/ChainSubresourceDataProvider.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace ApiPlatform\Core\DataProvider; + +use ApiPlatform\Core\Exception\ResourceClassNotSupportedException; + +/** + * Tries each configured data provider and returns the result of the first able to handle the resource class. + * + * @author Antoine Bluchet + */ +final class ChainSubresourceDataProvider implements SubresourceDataProviderInterface +{ + private $dataProviders; + + /** + * @param SubresourceDataProviderInterface[] $dataProviders + */ + public function __construct(array $dataProviders) + { + $this->dataProviders = $dataProviders; + } + + /** + * {@inheritdoc} + */ + public function getSubresource(string $resourceClass, array $identifiers, array $context, string $operationName) + { + foreach ($this->dataProviders as $dataProviders) { + try { + return $dataProviders->getSubresource($resourceClass, $identifiers, $context, $operationName); + } catch (ResourceClassNotSupportedException $e) { + continue; + } + } + } +} diff --git a/src/DataProvider/SubresourceDataProviderInterface.php b/src/DataProvider/SubresourceDataProviderInterface.php new file mode 100644 index 00000000000..1b89fcbe9e4 --- /dev/null +++ b/src/DataProvider/SubresourceDataProviderInterface.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace ApiPlatform\Core\DataProvider; + +use ApiPlatform\Core\Exception\ResourceClassNotSupportedException; + +/** + * Retrieves subresources from a persistence layer. + * + * @author Antoine Bluchet + */ +interface SubresourceDataProviderInterface +{ + /** + * Retrieves a subresource of an item. + * + * @param string $resourceClass The root resource class + * @param array $identifiers Identifiers and their values + * @param array $context The context indicate the conjuction between collection properties (identifiers) and their class + * @param string $operationName + * + * @throws ResourceClassNotSupportedException + * + * @return object|null + */ + public function getSubresource(string $resourceClass, array $identifiers, array $context, string $operationName); +} diff --git a/src/EventListener/DenyAccessListener.php b/src/EventListener/DenyAccessListener.php index d995b561793..ec9cb958bf0 100644 --- a/src/EventListener/DenyAccessListener.php +++ b/src/EventListener/DenyAccessListener.php @@ -53,8 +53,10 @@ public function onKernelRequest(GetResponseEvent $event) $resourceMetadata = $this->resourceMetadataFactory->create($attributes['resource_class']); if (isset($attributes['collection_operation_name'])) { $isGranted = $resourceMetadata->getCollectionOperationAttribute($attributes['collection_operation_name'], 'is_granted', null, true); - } else { + } elseif (isset($attributes['item_operation_name'])) { $isGranted = $resourceMetadata->getItemOperationAttribute($attributes['item_operation_name'], 'is_granted', null, true); + } else { + $isGranted = $resourceMetadata->getCollectionOperationAttribute($attributes['subresource_operation_name'], 'is_granted', null, true); } if (null === $isGranted) { diff --git a/src/EventListener/ReadListener.php b/src/EventListener/ReadListener.php index 434460df92b..ed313d4bbfe 100644 --- a/src/EventListener/ReadListener.php +++ b/src/EventListener/ReadListener.php @@ -16,6 +16,8 @@ use ApiPlatform\Core\DataProvider\CollectionDataProviderInterface; use ApiPlatform\Core\DataProvider\ItemDataProviderInterface; use ApiPlatform\Core\DataProvider\PaginatorInterface; +use ApiPlatform\Core\DataProvider\SubresourceDataProviderInterface; +use ApiPlatform\Core\Exception\RuntimeException; use ApiPlatform\Core\Util\RequestAttributesExtractor; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Event\GetResponseEvent; @@ -30,11 +32,13 @@ final class ReadListener { private $collectionDataProvider; private $itemDataProvider; + private $subresourceDataProvider; - public function __construct(CollectionDataProviderInterface $collectionDataProvider, ItemDataProviderInterface $itemDataProvider) + public function __construct(CollectionDataProviderInterface $collectionDataProvider, ItemDataProviderInterface $itemDataProvider, SubresourceDataProviderInterface $subresourceDataProvider = null) { $this->collectionDataProvider = $collectionDataProvider; $this->itemDataProvider = $itemDataProvider; + $this->subresourceDataProvider = $subresourceDataProvider; } /** @@ -54,10 +58,14 @@ public function onKernelRequest(GetResponseEvent $event) return; } - if (isset($attributes['collection_operation_name'])) { - $data = $this->getCollectionData($request, $attributes); - } else { + $data = []; + + if (isset($attributes['item_operation_name'])) { $data = $this->getItemData($request, $attributes); + } elseif (isset($attributes['collection_operation_name'])) { + $data = $this->getCollectionData($request, $attributes); + } elseif (isset($attributes['subresource_operation_name'])) { + $data = $this->getSubresourceData($request, $attributes); } $request->attributes->set('data', $data); @@ -101,4 +109,31 @@ private function getItemData(Request $request, array $attributes) return $data; } + + /** + * Gets data for a nested operation. + * + * @param Request $request + * @param array $attributes + * + * @throws NotFoundHttpException + * @throws RuntimeException + * + * @return object|null + */ + private function getSubresourceData(Request $request, array $attributes) + { + if (null === $this->subresourceDataProvider) { + throw new RuntimeException('No subresource data provider.'); + } + + $identifiers = []; + foreach ($attributes['subresource_context']['identifiers'] as $key => list($id)) { + $identifiers[$id] = $request->attributes->get($id); + } + + $data = $this->subresourceDataProvider->getSubresource($attributes['resource_class'], $identifiers, $attributes['subresource_context'], $attributes['subresource_operation_name']); + + return $data; + } } diff --git a/src/Hydra/Serializer/CollectionNormalizer.php b/src/Hydra/Serializer/CollectionNormalizer.php index 7eee607be5e..67bf29171b6 100644 --- a/src/Hydra/Serializer/CollectionNormalizer.php +++ b/src/Hydra/Serializer/CollectionNormalizer.php @@ -14,6 +14,7 @@ namespace ApiPlatform\Core\Hydra\Serializer; use ApiPlatform\Core\Api\IriConverterInterface; +use ApiPlatform\Core\Api\OperationType; use ApiPlatform\Core\Api\ResourceClassResolverInterface; use ApiPlatform\Core\DataProvider\PaginatorInterface; use ApiPlatform\Core\JsonLd\ContextBuilderInterface; @@ -74,7 +75,12 @@ public function normalize($object, $format = null, array $context = []) $data = $this->addJsonLdContext($this->contextBuilder, $resourceClass, $context); $context = $this->initContext($resourceClass, $context); - $data['@id'] = $this->iriConverter->getIriFromResourceClass($resourceClass); + if (isset($context['operation_type']) && $context['operation_type'] === OperationType::SUBRESOURCE) { + $data['@id'] = $this->iriConverter->getSubresourceIriFromResourceClass($resourceClass, $context['subresource_identifiers']); + } else { + $data['@id'] = $this->iriConverter->getIriFromResourceClass($resourceClass); + } + $data['@type'] = 'hydra:Collection'; $data['hydra:member'] = []; diff --git a/src/Metadata/Extractor/XmlExtractor.php b/src/Metadata/Extractor/XmlExtractor.php index be9d790349a..9f5552d0f84 100644 --- a/src/Metadata/Extractor/XmlExtractor.php +++ b/src/Metadata/Extractor/XmlExtractor.php @@ -130,6 +130,7 @@ private function getProperties(\SimpleXMLElement $resource): array 'identifier' => $this->phpize($property, 'identifier', 'bool'), 'iri' => $this->phpize($property, 'iri', 'string'), 'attributes' => $this->getAttributes($property, 'attribute'), + 'subresource' => $this->phpize($property, 'subresource', 'bool'), ]; } diff --git a/src/Metadata/Extractor/YamlExtractor.php b/src/Metadata/Extractor/YamlExtractor.php index f6883854821..141629d0060 100644 --- a/src/Metadata/Extractor/YamlExtractor.php +++ b/src/Metadata/Extractor/YamlExtractor.php @@ -107,6 +107,7 @@ private function extractProperties(array $resourceYaml, string $resourceName, st 'identifier' => $this->phpize($propertyValues, 'identifier', 'bool'), 'iri' => $this->phpize($propertyValues, 'iri', 'string'), 'attributes' => $propertyValues['attributes'] ?? null, + 'subresource' => $this->phpize($propertyValues, 'subresource', 'bool'), ]; } } diff --git a/src/Metadata/Property/Factory/AnnotationPropertyMetadataFactory.php b/src/Metadata/Property/Factory/AnnotationPropertyMetadataFactory.php index 3923ded17fa..94e49dba112 100644 --- a/src/Metadata/Property/Factory/AnnotationPropertyMetadataFactory.php +++ b/src/Metadata/Property/Factory/AnnotationPropertyMetadataFactory.php @@ -117,12 +117,13 @@ private function createMetadata(ApiProperty $annotation, PropertyMetadata $paren $annotation->identifier, $annotation->iri, null, - $annotation->attributes + $annotation->attributes, + $annotation->subresource ); } $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'], ['has', 'subresource']] as $property) { if (null !== $value = $annotation->{$property[1]}) { $propertyMetadata = $this->createWith($propertyMetadata, $property, $value); } diff --git a/src/Metadata/Property/Factory/ExtractorPropertyMetadataFactory.php b/src/Metadata/Property/Factory/ExtractorPropertyMetadataFactory.php index e8a7bd05c3f..22e935f2d02 100644 --- a/src/Metadata/Property/Factory/ExtractorPropertyMetadataFactory.php +++ b/src/Metadata/Property/Factory/ExtractorPropertyMetadataFactory.php @@ -69,7 +69,8 @@ public function create(string $resourceClass, string $property, array $options = $propertyMetadata['identifier'], $propertyMetadata['iri'], null, - $propertyMetadata['attributes'] + $propertyMetadata['attributes'], + $propertyMetadata['subresource'] ); } @@ -113,6 +114,7 @@ private function update(PropertyMetadata $propertyMetadata, array $metadata): Pr 'identifier' => 'is', 'iri' => 'get', 'attributes' => 'get', + 'subresource' => 'has', ]; foreach ($metadataAccessors as $metadataKey => $accessorPrefix) { diff --git a/src/Metadata/Property/PropertyMetadata.php b/src/Metadata/Property/PropertyMetadata.php index 79ecebaee58..3e4fb91eddf 100644 --- a/src/Metadata/Property/PropertyMetadata.php +++ b/src/Metadata/Property/PropertyMetadata.php @@ -33,8 +33,9 @@ final class PropertyMetadata private $identifier; private $childInherited; private $attributes; + private $subresource; - 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) + 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, bool $subresource = null) { $this->type = $type; $this->description = $description; @@ -47,6 +48,7 @@ public function __construct(Type $type = null, string $description = null, bool $this->iri = $iri; $this->childInherited = $childInherited; $this->attributes = $attributes; + $this->subresource = $subresource; } /** @@ -327,4 +329,17 @@ public function withChildInherited(string $childInherited): self return $metadata; } + + public function hasSubresource() + { + return $this->subresource; + } + + public function withSubresource(bool $subresource = null): self + { + $metadata = clone $this; + $metadata->subresource = $subresource; + + return $metadata; + } } diff --git a/src/PathResolver/CustomOperationPathResolver.php b/src/PathResolver/CustomOperationPathResolver.php index 3bd0d865183..19fb26c8175 100644 --- a/src/PathResolver/CustomOperationPathResolver.php +++ b/src/PathResolver/CustomOperationPathResolver.php @@ -13,6 +13,8 @@ namespace ApiPlatform\Core\PathResolver; +use ApiPlatform\Core\Api\OperationTypeDeprecationHelper; + /** * Resolves the custom operations path. * @@ -30,12 +32,12 @@ public function __construct(OperationPathResolverInterface $deferred) /** * {@inheritdoc} */ - public function resolveOperationPath(string $resourceShortName, array $operation, bool $collection): string + public function resolveOperationPath(string $resourceShortName, array $operation, $operationType): string { if (isset($operation['path'])) { return $operation['path']; } - return $this->deferred->resolveOperationPath($resourceShortName, $operation, $collection); + return $this->deferred->resolveOperationPath($resourceShortName, $operation, OperationTypeDeprecationHelper::getOperationType($operationType)); } } diff --git a/src/PathResolver/DashOperationPathResolver.php b/src/PathResolver/DashOperationPathResolver.php index 8b235cadbd7..48bd3e026e5 100644 --- a/src/PathResolver/DashOperationPathResolver.php +++ b/src/PathResolver/DashOperationPathResolver.php @@ -13,6 +13,8 @@ namespace ApiPlatform\Core\PathResolver; +use ApiPlatform\Core\Api\OperationType; +use ApiPlatform\Core\Api\OperationTypeDeprecationHelper; use Doctrine\Common\Inflector\Inflector; /** @@ -25,16 +27,33 @@ final class DashOperationPathResolver implements OperationPathResolverInterface /** * {@inheritdoc} */ - public function resolveOperationPath(string $resourceShortName, array $operation, bool $collection): string + public function resolveOperationPath(string $resourceShortName, array $operation, $operationType): string { - $path = '/'.Inflector::pluralize(strtolower(preg_replace('~(?<=\\w)([A-Z])~', '-$1', $resourceShortName))); + $operationType = OperationTypeDeprecationHelper::getOperationType($operationType); - if (!$collection) { + if ($operationType === OperationType::SUBRESOURCE && 1 < count($operation['identifiers'])) { + $path = str_replace('.{_format}', '', $resourceShortName); + } else { + $path = '/'.Inflector::pluralize($this->dashize($resourceShortName)); + } + + if ($operationType === OperationType::ITEM) { $path .= '/{id}'; } + if ($operationType === OperationType::SUBRESOURCE) { + list($key) = end($operation['identifiers']); + $property = true === $operation['collection'] ? Inflector::pluralize($this->dashize($operation['property'])) : $this->dashize($operation['property']); + $path .= sprintf('/{%s}/%s', $key, $property); + } + $path .= '.{_format}'; return $path; } + + private function dashize(string $string): string + { + return strtolower(preg_replace('~(?<=\\w)([A-Z])~', '-$1', $string)); + } } diff --git a/src/PathResolver/OperationPathResolverInterface.php b/src/PathResolver/OperationPathResolverInterface.php index bdbb21dfb7b..4ea1ff8c4bd 100644 --- a/src/PathResolver/OperationPathResolverInterface.php +++ b/src/PathResolver/OperationPathResolverInterface.php @@ -23,11 +23,12 @@ interface OperationPathResolverInterface /** * Resolves the operation path. * - * @param string $resourceShortName - * @param array $operation The operation metadata - * @param bool $collection + * @param string $resourceShortName When the operation type is a subresource and the operation has more than one identifier, this value is the previous operation path + * @param array $operation The operation metadata + * @param string|bool $operationType One of the constants defined in ApiPlatform\Core\Api\OperationType + * If the property is a boolean, true represents OperationType::COLLECTION, false is for OperationType::ITEM * * @return string */ - public function resolveOperationPath(string $resourceShortName, array $operation, bool $collection): string; + public function resolveOperationPath(string $resourceShortName, array $operation, $operationType): string; } diff --git a/src/PathResolver/UnderscoreOperationPathResolver.php b/src/PathResolver/UnderscoreOperationPathResolver.php index de4d5134cbc..befbca71854 100644 --- a/src/PathResolver/UnderscoreOperationPathResolver.php +++ b/src/PathResolver/UnderscoreOperationPathResolver.php @@ -13,6 +13,8 @@ namespace ApiPlatform\Core\PathResolver; +use ApiPlatform\Core\Api\OperationType; +use ApiPlatform\Core\Api\OperationTypeDeprecationHelper; use Doctrine\Common\Inflector\Inflector; /** @@ -25,14 +27,26 @@ final class UnderscoreOperationPathResolver implements OperationPathResolverInte /** * {@inheritdoc} */ - public function resolveOperationPath(string $resourceShortName, array $operation, bool $collection): string + public function resolveOperationPath(string $resourceShortName, array $operation, $operationType): string { - $path = '/'.Inflector::pluralize(Inflector::tableize($resourceShortName)); + $operationType = OperationTypeDeprecationHelper::getOperationType($operationType); - if (!$collection) { + if ($operationType === OperationType::SUBRESOURCE && 1 < count($operation['identifiers'])) { + $path = str_replace('.{_format}', '', $resourceShortName); + } else { + $path = '/'.Inflector::pluralize(Inflector::tableize($resourceShortName)); + } + + if ($operationType === OperationType::ITEM) { $path .= '/{id}'; } + if ($operationType === OperationType::SUBRESOURCE) { + list($key) = end($operation['identifiers']); + $property = true === $operation['collection'] ? Inflector::pluralize(Inflector::tableize($operation['property'])) : Inflector::tableize($operation['property']); + $path .= sprintf('/{%s}/%s', $key, $property); + } + $path .= '.{_format}'; return $path; diff --git a/src/Serializer/AbstractItemNormalizer.php b/src/Serializer/AbstractItemNormalizer.php index 2e6bb96f707..cf7e1a9798f 100644 --- a/src/Serializer/AbstractItemNormalizer.php +++ b/src/Serializer/AbstractItemNormalizer.php @@ -14,6 +14,7 @@ namespace ApiPlatform\Core\Serializer; use ApiPlatform\Core\Api\IriConverterInterface; +use ApiPlatform\Core\Api\OperationType; use ApiPlatform\Core\Api\ResourceClassResolverInterface; use ApiPlatform\Core\Exception\InvalidArgumentException; use ApiPlatform\Core\Exception\ItemNotFoundException; @@ -395,12 +396,19 @@ protected function getAttributeValue($object, $attribute, $format = null, array } if ( - $attributeValue && $type && ($className = $type->getClassName()) && $this->resourceClassResolver->isResourceClass($className) ) { - return $this->normalizeRelation($propertyMetadata, $attributeValue, $className, $format, $context); + /* + * On a subresource, we know the value of the identifiers. + * If attributeValue is null, meaning that it hasn't been returned by the DataProvider, get the item Iri + */ + if (null === $attributeValue && $context['operation_type'] === OperationType::SUBRESOURCE && isset($context['subresource_resources'][$className])) { + return $this->iriConverter->getItemIriFromResourceClass($className, $context['subresource_resources'][$className]); + } elseif ($attributeValue) { + return $this->normalizeRelation($propertyMetadata, $attributeValue, $className, $format, $context); + } } return $this->serializer->normalize($attributeValue, $format, $context); diff --git a/src/Serializer/SerializerContextBuilder.php b/src/Serializer/SerializerContextBuilder.php index 2cc69d2d475..40f423f08e5 100644 --- a/src/Serializer/SerializerContextBuilder.php +++ b/src/Serializer/SerializerContextBuilder.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Core\Serializer; +use ApiPlatform\Core\Api\OperationType; use ApiPlatform\Core\Exception\RuntimeException; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use ApiPlatform\Core\Util\RequestAttributesExtractor; @@ -44,14 +45,28 @@ public function createFromRequest(Request $request, bool $normalization, array $ $resourceMetadata = $this->resourceMetadataFactory->create($attributes['resource_class']); $key = $normalization ? 'normalization_context' : 'denormalization_context'; + $operationKey = null; + $operationType = null; + if (isset($attributes['collection_operation_name'])) { - $context = $resourceMetadata->getCollectionOperationAttribute($attributes['collection_operation_name'], $key, [], true); - $context['collection_operation_name'] = $attributes['collection_operation_name']; + $operationKey = 'collection_operation_name'; + $operationType = OperationType::COLLECTION; + } elseif (isset($attributes['subresource_operation_name'])) { + $operationKey = 'subresource_operation_name'; + $operationType = OperationType::SUBRESOURCE; + } + + if (null !== $operationKey) { + $attribute = $attributes[$operationKey]; + $context = $resourceMetadata->getCollectionOperationAttribute($attribute, $key, [], true); + $context[$operationKey] = $attribute; } else { $context = $resourceMetadata->getItemOperationAttribute($attributes['item_operation_name'], $key, [], true); $context['item_operation_name'] = $attributes['item_operation_name']; } + $context['operation_type'] = $operationType ? $operationType : OperationType::ITEM; + if (!$normalization && !isset($context['api_allow_update'])) { $context['api_allow_update'] = Request::METHOD_PUT === $request->getMethod(); } @@ -59,6 +74,23 @@ public function createFromRequest(Request $request, bool $normalization, array $ $context['resource_class'] = $attributes['resource_class']; $context['request_uri'] = $request->getRequestUri(); + if (isset($attributes['subresource_context'])) { + $context['subresource_identifiers'] = []; + + foreach ($attributes['subresource_context']['identifiers'] as $key => list($id, $resourceClass)) { + if (!isset($context['subresource_resources'][$resourceClass])) { + $context['subresource_resources'][$resourceClass] = []; + } + + $context['subresource_identifiers'][$id] = $context['subresource_resources'][$resourceClass][$id] = $request->attributes->get($id); + } + } + + if (isset($attributes['subresource_property'])) { + $context['subresource_property'] = $attributes['subresource_property']; + $context['subresource_resource_class'] = $attributes['subresource_resource_class'] ?? null; + } + return $context; } } diff --git a/src/Swagger/Serializer/DocumentationNormalizer.php b/src/Swagger/Serializer/DocumentationNormalizer.php index 1cb6bf470a5..c88cb36778c 100644 --- a/src/Swagger/Serializer/DocumentationNormalizer.php +++ b/src/Swagger/Serializer/DocumentationNormalizer.php @@ -15,6 +15,7 @@ use ApiPlatform\Core\Api\FilterCollection; use ApiPlatform\Core\Api\OperationMethodResolverInterface; +use ApiPlatform\Core\Api\OperationType; use ApiPlatform\Core\Api\ResourceClassResolverInterface; use ApiPlatform\Core\Api\UrlGeneratorInterface; use ApiPlatform\Core\Documentation\Documentation; @@ -89,8 +90,8 @@ public function normalize($object, $format = null, array $context = []) $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass); $resourceShortName = $resourceMetadata->getShortName(); - $this->addPaths($paths, $definitions, $resourceClass, $resourceShortName, $resourceMetadata, $mimeTypes, true); - $this->addPaths($paths, $definitions, $resourceClass, $resourceShortName, $resourceMetadata, $mimeTypes, false); + $this->addPaths($paths, $definitions, $resourceClass, $resourceShortName, $resourceMetadata, $mimeTypes, OperationType::COLLECTION); + $this->addPaths($paths, $definitions, $resourceClass, $resourceShortName, $resourceMetadata, $mimeTypes, OperationType::ITEM); } $definitions->ksort(); @@ -108,21 +109,19 @@ public function normalize($object, $format = null, array $context = []) * @param string $resourceShortName * @param ResourceMetadata $resourceMetadata * @param array $mimeTypes - * @param bool $collection + * @param string $operationType */ - private function addPaths(\ArrayObject $paths, \ArrayObject $definitions, string $resourceClass, string $resourceShortName, ResourceMetadata $resourceMetadata, array $mimeTypes, bool $collection) + private function addPaths(\ArrayObject $paths, \ArrayObject $definitions, string $resourceClass, string $resourceShortName, ResourceMetadata $resourceMetadata, array $mimeTypes, string $operationType) { - $operations = $collection ? $resourceMetadata->getCollectionOperations() : $resourceMetadata->getItemOperations(); - - if (!$operations) { + if (null === $operations = $operationType === OperationType::COLLECTION ? $resourceMetadata->getCollectionOperations() : $resourceMetadata->getItemOperations()) { return; } foreach ($operations as $operationName => $operation) { - $path = $this->getPath($resourceShortName, $operation, $collection); - $method = $collection ? $this->operationMethodResolver->getCollectionOperationMethod($resourceClass, $operationName) : $this->operationMethodResolver->getItemOperationMethod($resourceClass, $operationName); + $path = $this->getPath($resourceShortName, $operation, $operationType); + $method = $operationType === OperationType::ITEM ? $this->operationMethodResolver->getItemOperationMethod($resourceClass, $operationName) : $this->operationMethodResolver->getCollectionOperationMethod($resourceClass, $operationName); - $paths[$path][strtolower($method)] = $this->getPathOperation($operationName, $operation, $method, $collection, $resourceClass, $resourceMetadata, $mimeTypes, $definitions); + $paths[$path][strtolower($method)] = $this->getPathOperation($operationName, $operation, $method, $operationType, $resourceClass, $resourceMetadata, $mimeTypes, $definitions); } } @@ -136,13 +135,13 @@ private function addPaths(\ArrayObject $paths, \ArrayObject $definitions, string * * @param string $resourceShortName * @param array $operation - * @param bool $collection + * @param string $operationType * * @return string */ - private function getPath(string $resourceShortName, array $operation, bool $collection): string + private function getPath(string $resourceShortName, array $operation, string $operationType): string { - $path = $this->operationPathResolver->resolveOperationPath($resourceShortName, $operation, $collection); + $path = $this->operationPathResolver->resolveOperationPath($resourceShortName, $operation, $operationType); if ('.{_format}' === substr($path, -10)) { $path = substr($path, 0, -10); } @@ -158,7 +157,7 @@ private function getPath(string $resourceShortName, array $operation, bool $coll * @param string $operationName * @param array $operation * @param string $method - * @param bool $collection + * @param string $operationType * @param string $resourceClass * @param ResourceMetadata $resourceMetadata * @param string[] $mimeTypes @@ -166,20 +165,20 @@ private function getPath(string $resourceShortName, array $operation, bool $coll * * @return \ArrayObject */ - private function getPathOperation(string $operationName, array $operation, string $method, bool $collection, string $resourceClass, ResourceMetadata $resourceMetadata, array $mimeTypes, \ArrayObject $definitions): \ArrayObject + private function getPathOperation(string $operationName, array $operation, string $method, string $operationType, string $resourceClass, ResourceMetadata $resourceMetadata, array $mimeTypes, \ArrayObject $definitions): \ArrayObject { $pathOperation = new \ArrayObject($operation['swagger_context'] ?? []); $resourceShortName = $resourceMetadata->getShortName(); $pathOperation['tags'] ?? $pathOperation['tags'] = [$resourceShortName]; - $pathOperation['operationId'] ?? $pathOperation['operationId'] = lcfirst($operationName).ucfirst($resourceShortName).ucfirst($collection ? 'collection' : 'item'); + $pathOperation['operationId'] ?? $pathOperation['operationId'] = lcfirst($operationName).ucfirst($resourceShortName).ucfirst($operationType); switch ($method) { case 'GET': - return $this->updateGetOperation($pathOperation, $mimeTypes, $collection, $resourceMetadata, $resourceClass, $resourceShortName, $operationName, $definitions); + return $this->updateGetOperation($pathOperation, $mimeTypes, $operationType, $resourceMetadata, $resourceClass, $resourceShortName, $operationName, $definitions); case 'POST': - return $this->updatePostOperation($pathOperation, $mimeTypes, $collection, $resourceMetadata, $resourceClass, $resourceShortName, $operationName, $definitions); + return $this->updatePostOperation($pathOperation, $mimeTypes, $operationType, $resourceMetadata, $resourceClass, $resourceShortName, $operationName, $definitions); case 'PUT': - return $this->updatePutOperation($pathOperation, $mimeTypes, $collection, $resourceMetadata, $resourceClass, $resourceShortName, $operationName, $definitions); + return $this->updatePutOperation($pathOperation, $mimeTypes, $operationType, $resourceMetadata, $resourceClass, $resourceShortName, $operationName, $definitions); case 'DELETE': return $this->updateDeleteOperation($pathOperation, $resourceShortName); default: @@ -190,7 +189,7 @@ private function getPathOperation(string $operationName, array $operation, strin /** * @param \ArrayObject $pathOperation * @param array $mimeTypes - * @param bool $collection + * @param string $operationType * @param ResourceMetadata $resourceMetadata * @param string $resourceClass * @param string $resourceShortName @@ -199,14 +198,14 @@ private function getPathOperation(string $operationName, array $operation, strin * * @return \ArrayObject */ - private function updateGetOperation(\ArrayObject $pathOperation, array $mimeTypes, bool $collection, ResourceMetadata $resourceMetadata, string $resourceClass, string $resourceShortName, string $operationName, \ArrayObject $definitions) + private function updateGetOperation(\ArrayObject $pathOperation, array $mimeTypes, string $operationType, ResourceMetadata $resourceMetadata, string $resourceClass, string $resourceShortName, string $operationName, \ArrayObject $definitions) { - $serializerContext = $this->getSerializerContext($collection, false, $resourceMetadata, $operationName); + $serializerContext = $this->getSerializerContext($operationType, false, $resourceMetadata, $operationName); $responseDefinitionKey = $this->getDefinition($definitions, $resourceMetadata, $resourceClass, $serializerContext); $pathOperation['produces'] ?? $pathOperation['produces'] = $mimeTypes; - if ($collection) { + if ($operationType === OperationType::COLLECTION || $operationType === OperationType::SUBRESOURCE) { $pathOperation['summary'] ?? $pathOperation['summary'] = sprintf('Retrieves the collection of %s resources.', $resourceShortName); $pathOperation['responses'] ?? $pathOperation['responses'] = [ '200' => [ @@ -246,7 +245,7 @@ private function updateGetOperation(\ArrayObject $pathOperation, array $mimeType /** * @param \ArrayObject $pathOperation * @param array $mimeTypes - * @param bool $collection + * @param string $operationType * @param ResourceMetadata $resourceMetadata * @param string $resourceClass * @param string $resourceShortName @@ -255,7 +254,7 @@ private function updateGetOperation(\ArrayObject $pathOperation, array $mimeType * * @return \ArrayObject */ - private function updatePostOperation(\ArrayObject $pathOperation, array $mimeTypes, bool $collection, ResourceMetadata $resourceMetadata, string $resourceClass, string $resourceShortName, string $operationName, \ArrayObject $definitions) + private function updatePostOperation(\ArrayObject $pathOperation, array $mimeTypes, string $operationType, ResourceMetadata $resourceMetadata, string $resourceClass, string $resourceShortName, string $operationName, \ArrayObject $definitions) { $pathOperation['consumes'] ?? $pathOperation['consumes'] = $mimeTypes; $pathOperation['produces'] ?? $pathOperation['produces'] = $mimeTypes; @@ -265,14 +264,14 @@ private function updatePostOperation(\ArrayObject $pathOperation, array $mimeTyp 'in' => 'body', 'description' => sprintf('The new %s resource', $resourceShortName), 'schema' => ['$ref' => sprintf('#/definitions/%s', $this->getDefinition($definitions, $resourceMetadata, $resourceClass, - $this->getSerializerContext($collection, true, $resourceMetadata, $operationName) + $this->getSerializerContext($operationType, true, $resourceMetadata, $operationName) ))], ]]; $pathOperation['responses'] ?? $pathOperation['responses'] = [ '201' => [ 'description' => sprintf('%s resource created', $resourceShortName), 'schema' => ['$ref' => sprintf('#/definitions/%s', $this->getDefinition($definitions, $resourceMetadata, $resourceClass, - $this->getSerializerContext($collection, false, $resourceMetadata, $operationName) + $this->getSerializerContext($operationType, false, $resourceMetadata, $operationName) ))], ], '400' => ['description' => 'Invalid input'], @@ -285,7 +284,7 @@ private function updatePostOperation(\ArrayObject $pathOperation, array $mimeTyp /** * @param \ArrayObject $pathOperation * @param array $mimeTypes - * @param bool $collection + * @param string $operationType * @param ResourceMetadata $resourceMetadata * @param string $resourceClass * @param string $resourceShortName @@ -294,7 +293,7 @@ private function updatePostOperation(\ArrayObject $pathOperation, array $mimeTyp * * @return \ArrayObject */ - private function updatePutOperation(\ArrayObject $pathOperation, array $mimeTypes, bool $collection, ResourceMetadata $resourceMetadata, string $resourceClass, string $resourceShortName, string $operationName, \ArrayObject $definitions) + private function updatePutOperation(\ArrayObject $pathOperation, array $mimeTypes, string $operationType, ResourceMetadata $resourceMetadata, string $resourceClass, string $resourceShortName, string $operationName, \ArrayObject $definitions) { $pathOperation['consumes'] ?? $pathOperation['consumes'] = $mimeTypes; $pathOperation['produces'] ?? $pathOperation['produces'] = $mimeTypes; @@ -311,7 +310,7 @@ private function updatePutOperation(\ArrayObject $pathOperation, array $mimeType 'in' => 'body', 'description' => sprintf('The updated %s resource', $resourceShortName), 'schema' => ['$ref' => sprintf('#/definitions/%s', $this->getDefinition($definitions, $resourceMetadata, $resourceClass, - $this->getSerializerContext($collection, true, $resourceMetadata, $operationName) + $this->getSerializerContext($operationType, true, $resourceMetadata, $operationName) ))], ], ]; @@ -319,7 +318,7 @@ private function updatePutOperation(\ArrayObject $pathOperation, array $mimeType '200' => [ 'description' => sprintf('%s resource updated', $resourceShortName), 'schema' => ['$ref' => sprintf('#/definitions/%s', $this->getDefinition($definitions, $resourceMetadata, $resourceClass, - $this->getSerializerContext($collection, false, $resourceMetadata, $operationName) + $this->getSerializerContext($operationType, false, $resourceMetadata, $operationName) ))], ], '400' => ['description' => 'Invalid input'], @@ -614,18 +613,18 @@ public function supportsNormalization($data, $format = null) } /** - * @param bool $collection + * @param string $operationType * @param bool $denormalization * @param ResourceMetadata $resourceMetadata - * @param string $operationName + * @param string $operationType * * @return array|null */ - private function getSerializerContext(bool $collection, bool $denormalization, ResourceMetadata $resourceMetadata, string $operationName) + private function getSerializerContext(string $operationType, bool $denormalization, ResourceMetadata $resourceMetadata, string $operationName) { $contextKey = $denormalization ? 'denormalization_context' : 'normalization_context'; - if ($collection) { + if (OperationType::COLLECTION === $operationType) { return $resourceMetadata->getCollectionOperationAttribute($operationName, $contextKey, null, true); } diff --git a/src/Util/RequestAttributesExtractor.php b/src/Util/RequestAttributesExtractor.php index 917d37e9f5a..d35e9bd77cf 100644 --- a/src/Util/RequestAttributesExtractor.php +++ b/src/Util/RequestAttributesExtractor.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Core\Util; +use ApiPlatform\Core\Api\OperationType; use Symfony\Component\HttpFoundation\Request; /** @@ -38,20 +39,29 @@ private function __construct() */ public static function extractAttributes(Request $request) { - $result = ['resource_class' => $request->attributes->get('_api_resource_class')]; + $result = [ + 'resource_class' => $request->attributes->get('_api_resource_class'), + ]; + + if ($subresourceContext = $request->attributes->get('_api_subresource_context')) { + $result['subresource_context'] = $subresourceContext; + } if (null === $result['resource_class']) { return []; } - $collectionOperationName = $request->attributes->get('_api_collection_operation_name'); - $itemOperationName = $request->attributes->get('_api_item_operation_name'); + $hasRequestAttributeKey = false; + foreach (OperationType::TYPES as $operationType) { + $attribute = "_api_{$operationType}_operation_name"; + if ($request->attributes->has($attribute)) { + $result["{$operationType}_operation_name"] = $request->attributes->get($attribute); + $hasRequestAttributeKey = true; + break; + } + } - if ($collectionOperationName) { - $result['collection_operation_name'] = $collectionOperationName; - } elseif ($itemOperationName) { - $result['item_operation_name'] = $itemOperationName; - } else { + if (false === $hasRequestAttributeKey) { return []; } diff --git a/tests/Bridge/Doctrine/Orm/SubresourceDataProviderTest.php b/tests/Bridge/Doctrine/Orm/SubresourceDataProviderTest.php new file mode 100644 index 00000000000..4313b45a01c --- /dev/null +++ b/tests/Bridge/Doctrine/Orm/SubresourceDataProviderTest.php @@ -0,0 +1,362 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace ApiPlatform\Core\Tests\Bridge\Doctrine\Orm; + +use ApiPlatform\Core\Bridge\Doctrine\Orm\Extension\QueryResultCollectionExtensionInterface; +use ApiPlatform\Core\Bridge\Doctrine\Orm\SubresourceDataProvider; +use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface; +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\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\RelatedDummy; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\ThirdLevel; +use Doctrine\Common\Persistence\ManagerRegistry; +use Doctrine\Common\Persistence\ObjectManager; +use Doctrine\Common\Persistence\ObjectRepository; +use Doctrine\DBAL\Connection; +use Doctrine\DBAL\Platforms\AbstractPlatform; +use Doctrine\ORM\AbstractQuery; +use Doctrine\ORM\EntityManager; +use Doctrine\ORM\EntityRepository; +use Doctrine\ORM\Mapping\ClassMetadata; +use Doctrine\ORM\Query\Expr; +use Doctrine\ORM\Query\Expr\Func; +use Doctrine\ORM\QueryBuilder; +use Prophecy\Argument; + +/** + * @author Kévin Dunglas + */ +class SubresourceDataProviderTest extends \PHPUnit_Framework_TestCase +{ + private function assertIdentifierManagerMethodCalls($managerProphecy) + { + $platformProphecy = $this->prophesize(AbstractPlatform::class); + + $connectionProphecy = $this->prophesize(Connection::class); + $connectionProphecy->getDatabasePlatform()->willReturn($platformProphecy); + + $managerProphecy->getConnection()->willReturn($connectionProphecy); + } + + private function getMetadataProphecies(array $resourceClassesIdentifiers) + { + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + + foreach ($resourceClassesIdentifiers as $resourceClass => $identifiers) { + $nameCollection = ['foobar']; + + foreach ($identifiers as $identifier) { + $metadata = new PropertyMetadata(); + $metadata = $metadata->withIdentifier(true); + $propertyMetadataFactoryProphecy->create($resourceClass, $identifier)->willReturn($metadata); + + $nameCollection[] = $identifier; + } + + //random property to prevent the use of non-identifiers metadata while looping + $propertyMetadataFactoryProphecy->create($resourceClass, 'foobar')->willReturn(new PropertyMetadata()); + + $propertyNameCollectionFactoryProphecy->create($resourceClass)->willReturn(new PropertyNameCollection($nameCollection)); + } + + return [$propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal()]; + } + + private function getManagerRegistryProphecy(QueryBuilder $queryBuilder, array $identifiers, string $resourceClass) + { + $classMetadataProphecy = $this->prophesize(ClassMetadata::class); + $classMetadataProphecy->getIdentifier()->willReturn($identifiers); + + foreach ($identifiers as $id) { + $classMetadataProphecy->getTypeOfField($id)->willReturn('interger'); + } + + $repositoryProphecy = $this->prophesize(EntityRepository::class); + $repositoryProphecy->createQueryBuilder('o')->willReturn($queryBuilder); + + $managerProphecy = $this->prophesize(ObjectManager::class); + $managerProphecy->getClassMetadata($resourceClass)->willReturn($classMetadataProphecy->reveal()); + $managerProphecy->getRepository($resourceClass)->willReturn($repositoryProphecy->reveal()); + + $managerRegistryProphecy = $this->prophesize(ManagerRegistry::class); + $managerRegistryProphecy->getManagerForClass($resourceClass)->willReturn($managerProphecy->reveal()); + + return $managerRegistryProphecy->reveal(); + } + + /** + * @expectedException \ApiPlatform\Core\Exception\ResourceClassNotSupportedException + * @expectedExceptionMessage The given resource class is not a subresource. + */ + public function testNotASubresource() + { + $identifiers = ['id']; + list($propertyNameCollectionFactory, $propertyMetadataFactory) = $this->getMetadataProphecies([Dummy::class => $identifiers]); + $queryBuilder = $this->prophesize(QueryBuilder::class)->reveal(); + $managerRegistry = $this->getManagerRegistryProphecy($queryBuilder, $identifiers, Dummy::class); + + $dataProvider = new SubresourceDataProvider($managerRegistry, $propertyNameCollectionFactory, $propertyMetadataFactory, []); + + $dataProvider->getSubresource(Dummy::class, ['id' => 1], []); + } + + public function testGetSubresource() + { + $dql = 'SELECT relatedDummies_a2 FROM ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy id_a1 INNER JOIN id_a1.relatedDummies relatedDummies_a2 WHERE id_a1.id = :id_p1'; + + $queryProphecy = $this->prophesize(AbstractQuery::class); + $queryProphecy->getResult()->shouldBeCalled()->willReturn([]); + + $identifiers = ['id']; + $queryBuilder = $this->prophesize(QueryBuilder::class); + $queryBuilder->setParameter('id_p1', 1)->shouldBeCalled()->willReturn($queryBuilder); + $funcProphecy = $this->prophesize(Func::class); + $func = $funcProphecy->reveal(); + + $exprProphecy = $this->prophesize(Expr::class); + $exprProphecy->in('o', $dql)->willReturn($func)->shouldBeCalled(); + + $queryBuilder->expr()->shouldBeCalled()->willReturn($exprProphecy->reveal()); + $queryBuilder->where($func)->shouldBeCalled()->willReturn($queryBuilder); + + $queryBuilder->getQuery()->shouldBeCalled()->willReturn($queryProphecy->reveal()); + + $repositoryProphecy = $this->prophesize(EntityRepository::class); + $repositoryProphecy->createQueryBuilder('o')->shouldBeCalled()->willReturn($queryBuilder->reveal()); + + $managerProphecy = $this->prophesize(EntityManager::class); + $managerProphecy->getRepository(RelatedDummy::class)->shouldBeCalled()->willReturn($repositoryProphecy->reveal()); + $this->assertIdentifierManagerMethodCalls($managerProphecy); + + $classMetadataProphecy = $this->prophesize(ClassMetadata::class); + $classMetadataProphecy->getIdentifier()->shouldBeCalled()->willReturn($identifiers); + $classMetadataProphecy->getTypeOfField('id')->shouldBeCalled()->willReturn('integer'); + $classMetadataProphecy->getAssociationMapping('relatedDummies')->shouldBeCalled()->willReturn(['type' => ClassMetadata::MANY_TO_MANY]); + + $managerProphecy->getClassMetadata(Dummy::class)->shouldBeCalled()->willReturn($classMetadataProphecy->reveal()); + + $qb = $this->prophesize(QueryBuilder::class); + $qb->select('relatedDummies_a2')->shouldBeCalled()->willReturn($qb); + $qb->from(Dummy::class, 'id_a1')->shouldBeCalled()->willReturn($qb); + $qb->innerJoin('id_a1.relatedDummies', 'relatedDummies_a2')->shouldBeCalled()->willReturn($qb); + $qb->andWhere('id_a1.id = :id_p1')->shouldBeCalled()->willReturn($qb); + $qb->getDQL()->shouldBeCalled()->willReturn($dql); + + $managerProphecy->createQueryBuilder()->shouldBeCalled()->willReturn($qb->reveal()); + + $managerRegistryProphecy = $this->prophesize(ManagerRegistry::class); + $managerRegistryProphecy->getManagerForClass(RelatedDummy::class)->shouldBeCalled()->willReturn($managerProphecy->reveal()); + $managerRegistryProphecy->getManagerForClass(Dummy::class)->shouldBeCalled()->willReturn($managerProphecy->reveal()); + + list($propertyNameCollectionFactory, $propertyMetadataFactory) = $this->getMetadataProphecies([Dummy::class => $identifiers]); + + $dataProvider = new SubresourceDataProvider($managerRegistryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory); + + $context = ['property' => 'relatedDummies', 'identifiers' => [['id', Dummy::class]], 'collection' => true]; + + $this->assertEquals([], $dataProvider->getSubresource(RelatedDummy::class, ['id' => 1], $context)); + } + + public function testGetSubSubresourceItem() + { + $managerRegistryProphecy = $this->prophesize(ManagerRegistry::class); + $identifiers = ['id']; + + // First manager (Dummy) + $dummyDQL = 'SELECT relatedDummies_a3 FROM ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy id_a2 INNER JOIN id_a2.relatedDummies relatedDummies_a3 WHERE id_a2.id = :id_p2'; + + $qb = $this->prophesize(QueryBuilder::class); + $qb->select('relatedDummies_a3')->shouldBeCalled()->willReturn($qb); + $qb->from(Dummy::class, 'id_a2')->shouldBeCalled()->willReturn($qb); + $qb->innerJoin('id_a2.relatedDummies', 'relatedDummies_a3')->shouldBeCalled()->willReturn($qb); + $qb->andWhere('id_a2.id = :id_p2')->shouldBeCalled()->willReturn($qb); + + $dummyFunc = new Func('in', ['any']); + + $dummyExpProphecy = $this->prophesize(Expr::class); + $dummyExpProphecy->in('relatedDummies_a1', $dummyDQL)->willReturn($dummyFunc)->shouldBeCalled(); + + $qb->expr()->shouldBeCalled()->willReturn($dummyExpProphecy->reveal()); + + $qb->getDQL()->shouldBeCalled()->willReturn($dummyDQL); + + $classMetadataProphecy = $this->prophesize(ClassMetadata::class); + $classMetadataProphecy->getIdentifier()->shouldBeCalled()->willReturn($identifiers); + $classMetadataProphecy->getTypeOfField('id')->shouldBeCalled()->willReturn('integer'); + $classMetadataProphecy->getAssociationMapping('relatedDummies')->shouldBeCalled()->willReturn(['type' => ClassMetadata::MANY_TO_MANY]); + + $dummyManagerProphecy = $this->prophesize(EntityManager::class); + $dummyManagerProphecy->createQueryBuilder()->shouldBeCalled()->willReturn($qb->reveal()); + $dummyManagerProphecy->getClassMetadata(Dummy::class)->shouldBeCalled()->willReturn($classMetadataProphecy->reveal()); + $this->assertIdentifierManagerMethodCalls($dummyManagerProphecy); + + $managerRegistryProphecy->getManagerForClass(Dummy::class)->shouldBeCalled()->willReturn($dummyManagerProphecy->reveal()); + + // Second manager (RelatedDummy) + $relatedDQL = 'SELECT IDENTITY(relatedDummies_a1.thirdLevel) FROM ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\RelatedDummy relatedDummies_a1 WHERE relatedDummies_a1.id = :id_p1 AND relatedDummies_a1 IN(SELECT relatedDummies_a3 FROM ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy id_a2 INNER JOIN id_a2.relatedDummies relatedDummies_a3 WHERE id_a2.id = :id_p2)'; + + $rqb = $this->prophesize(QueryBuilder::class); + $rqb->select('IDENTITY(relatedDummies_a1.thirdLevel)')->shouldBeCalled()->willReturn($rqb); + $rqb->from(RelatedDummy::class, 'relatedDummies_a1')->shouldBeCalled()->willReturn($rqb); + $rqb->andWhere('relatedDummies_a1.id = :id_p1')->shouldBeCalled()->willReturn($rqb); + $rqb->andWhere($dummyFunc)->shouldBeCalled()->willReturn($rqb); + $rqb->getDQL()->shouldBeCalled()->willReturn($relatedDQL); + + $rClassMetadataProphecy = $this->prophesize(ClassMetadata::class); + $rClassMetadataProphecy->getIdentifier()->shouldBeCalled()->willReturn($identifiers); + $rClassMetadataProphecy->getTypeOfField('id')->shouldBeCalled()->willReturn('integer'); + $rClassMetadataProphecy->getAssociationMapping('thirdLevel')->shouldBeCalled()->willReturn(['type' => ClassMetadata::MANY_TO_ONE]); + + $rDummyManagerProphecy = $this->prophesize(EntityManager::class); + $rDummyManagerProphecy->createQueryBuilder()->shouldBeCalled()->willReturn($rqb->reveal()); + $rDummyManagerProphecy->getClassMetadata(RelatedDummy::class)->shouldBeCalled()->willReturn($rClassMetadataProphecy->reveal()); + $this->assertIdentifierManagerMethodCalls($rDummyManagerProphecy); + + $managerRegistryProphecy->getManagerForClass(RelatedDummy::class)->shouldBeCalled()->willReturn($rDummyManagerProphecy->reveal()); + + $result = new \StdClass(); + // Origin manager (ThirdLevel) + $queryProphecy = $this->prophesize(AbstractQuery::class); + $queryProphecy->getOneOrNullResult()->shouldBeCalled()->willReturn($result); + + $queryBuilder = $this->prophesize(QueryBuilder::class); + + $funcProphecy = $this->prophesize(Func::class); + $func = $funcProphecy->reveal(); + + $exprProphecy = $this->prophesize(Expr::class); + $exprProphecy->in('o', $relatedDQL)->willReturn($func)->shouldBeCalled(); + + $queryBuilder->expr()->shouldBeCalled()->willReturn($exprProphecy->reveal()); + $queryBuilder->where($func)->shouldBeCalled()->willReturn($queryBuilder); + + $queryBuilder->getQuery()->shouldBeCalled()->willReturn($queryProphecy->reveal()); + $queryBuilder->setParameter('id_p1', 1)->shouldBeCalled()->willReturn($queryBuilder); + $queryBuilder->setParameter('id_p2', 1)->shouldBeCalled()->willReturn($queryBuilder); + + $repositoryProphecy = $this->prophesize(EntityRepository::class); + $repositoryProphecy->createQueryBuilder('o')->shouldBeCalled()->willReturn($queryBuilder->reveal()); + + $managerProphecy = $this->prophesize(ObjectManager::class); + $managerProphecy->getRepository(ThirdLevel::class)->shouldBeCalled()->willReturn($repositoryProphecy->reveal()); + + $managerRegistryProphecy->getManagerForClass(ThirdLevel::class)->shouldBeCalled()->willReturn($managerProphecy->reveal()); + + list($propertyNameCollectionFactory, $propertyMetadataFactory) = $this->getMetadataProphecies([Dummy::class => $identifiers, RelatedDummy::class => $identifiers]); + + $dataProvider = new SubresourceDataProvider($managerRegistryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory); + + $context = ['property' => 'thirdLevel', 'identifiers' => [['id', Dummy::class], ['relatedDummies', RelatedDummy::class]], 'collection' => false]; + + $this->assertEquals($result, $dataProvider->getSubresource(ThirdLevel::class, ['id' => 1, 'relatedDummies' => 1], $context)); + } + + public function testQueryResultExtension() + { + $dql = 'SELECT relatedDummies_a2 FROM ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy id_a1 INNER JOIN id_a1.relatedDummies relatedDummies_a2 WHERE id_a1.id = :id_p1'; + + $identifiers = ['id']; + $queryBuilder = $this->prophesize(QueryBuilder::class); + $queryBuilder->setParameter('id_p1', 1)->shouldBeCalled()->willReturn($queryBuilder); + $funcProphecy = $this->prophesize(Func::class); + $func = $funcProphecy->reveal(); + + $exprProphecy = $this->prophesize(Expr::class); + $exprProphecy->in('o', $dql)->willReturn($func)->shouldBeCalled(); + + $queryBuilder->expr()->shouldBeCalled()->willReturn($exprProphecy->reveal()); + $queryBuilder->where($func)->shouldBeCalled()->willReturn($queryBuilder); + + $repositoryProphecy = $this->prophesize(EntityRepository::class); + $repositoryProphecy->createQueryBuilder('o')->shouldBeCalled()->willReturn($queryBuilder->reveal()); + + $managerProphecy = $this->prophesize(EntityManager::class); + $managerProphecy->getRepository(RelatedDummy::class)->shouldBeCalled()->willReturn($repositoryProphecy->reveal()); + $this->assertIdentifierManagerMethodCalls($managerProphecy); + + $classMetadataProphecy = $this->prophesize(ClassMetadata::class); + $classMetadataProphecy->getIdentifier()->shouldBeCalled()->willReturn($identifiers); + $classMetadataProphecy->getTypeOfField('id')->shouldBeCalled()->willReturn('integer'); + $classMetadataProphecy->getAssociationMapping('relatedDummies')->shouldBeCalled()->willReturn(['type' => ClassMetadata::MANY_TO_MANY]); + + $managerProphecy->getClassMetadata(Dummy::class)->shouldBeCalled()->willReturn($classMetadataProphecy->reveal()); + $this->assertIdentifierManagerMethodCalls($managerProphecy); + + $qb = $this->prophesize(QueryBuilder::class); + $qb->select('relatedDummies_a2')->shouldBeCalled()->willReturn($qb); + $qb->from(Dummy::class, 'id_a1')->shouldBeCalled()->willReturn($qb); + $qb->innerJoin('id_a1.relatedDummies', 'relatedDummies_a2')->shouldBeCalled()->willReturn($qb); + $qb->andWhere('id_a1.id = :id_p1')->shouldBeCalled()->willReturn($qb); + $qb->getDQL()->shouldBeCalled()->willReturn($dql); + + $managerProphecy->createQueryBuilder()->shouldBeCalled()->willReturn($qb->reveal()); + $this->assertIdentifierManagerMethodCalls($managerProphecy); + + $managerRegistryProphecy = $this->prophesize(ManagerRegistry::class); + $managerRegistryProphecy->getManagerForClass(RelatedDummy::class)->shouldBeCalled()->willReturn($managerProphecy->reveal()); + $managerRegistryProphecy->getManagerForClass(Dummy::class)->shouldBeCalled()->willReturn($managerProphecy->reveal()); + + list($propertyNameCollectionFactory, $propertyMetadataFactory) = $this->getMetadataProphecies([Dummy::class => $identifiers]); + + $extensionProphecy = $this->prophesize(QueryResultCollectionExtensionInterface::class); + $extensionProphecy->applyToCollection($queryBuilder, Argument::type(QueryNameGeneratorInterface::class), RelatedDummy::class, null)->shouldBeCalled(); + $extensionProphecy->supportsResult(RelatedDummy::class, null)->willReturn(true)->shouldBeCalled(); + $extensionProphecy->getResult($queryBuilder)->willReturn([])->shouldBeCalled(); + + $dataProvider = new SubresourceDataProvider($managerRegistryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory, [$extensionProphecy->reveal()]); + + $context = ['property' => 'relatedDummies', 'identifiers' => [['id', Dummy::class]], 'collection' => true]; + + $this->assertEquals([], $dataProvider->getSubresource(RelatedDummy::class, ['id' => 1], $context)); + } + + /** + * @expectedException \ApiPlatform\Core\Exception\RuntimeException + * @expectedExceptionMessage The repository class must have a "createQueryBuilder" method. + */ + public function testCannotCreateQueryBuilder() + { + $identifiers = ['id']; + $repositoryProphecy = $this->prophesize(ObjectRepository::class); + + $managerProphecy = $this->prophesize(ObjectManager::class); + $managerProphecy->getRepository(Dummy::class)->willReturn($repositoryProphecy->reveal())->shouldBeCalled(); + + $managerRegistryProphecy = $this->prophesize(ManagerRegistry::class); + $managerRegistryProphecy->getManagerForClass(Dummy::class)->willReturn($managerProphecy->reveal())->shouldBeCalled(); + + list($propertyNameCollectionFactory, $propertyMetadataFactory) = $this->getMetadataProphecies([Dummy::class => $identifiers]); + + $dataProvider = new SubresourceDataProvider($managerRegistryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory); + $dataProvider->getSubresource(Dummy::class, ['id' => 1], []); + } + + /** + * @expectedException \ApiPlatform\Core\Exception\ResourceClassNotSupportedException + */ + public function testThrowResourceClassNotSupportedException() + { + $identifiers = ['id']; + $managerRegistryProphecy = $this->prophesize(ManagerRegistry::class); + $managerRegistryProphecy->getManagerForClass(Dummy::class)->willReturn(null)->shouldBeCalled(); + + list($propertyNameCollectionFactory, $propertyMetadataFactory) = $this->getMetadataProphecies([Dummy::class => $identifiers]); + + $dataProvider = new SubresourceDataProvider($managerRegistryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory); + $dataProvider->getSubresource(Dummy::class, ['id' => 1], []); + } +} diff --git a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php index c519766868a..38079e5b84f 100644 --- a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php +++ b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php @@ -287,6 +287,7 @@ private function getContainerBuilderProphecy() 'api_platform.doctrine.orm.default.collection_data_provider', 'api_platform.doctrine.orm.default.item_data_provider', 'api_platform.doctrine.orm.exists_filter', + 'api_platform.doctrine.orm.default.subresource_data_provider', 'api_platform.doctrine.orm.item_data_provider', 'api_platform.doctrine.orm.metadata.property.metadata_factory', 'api_platform.doctrine.orm.numeric_filter', @@ -298,6 +299,7 @@ private function getContainerBuilderProphecy() 'api_platform.doctrine.orm.query_extension.pagination', 'api_platform.doctrine.orm.range_filter', 'api_platform.doctrine.orm.search_filter', + 'api_platform.doctrine.orm.subresource_data_provider', 'api_platform.filters', 'api_platform.doctrine.listener.view.write', 'api_platform.jsonld.normalizer.item', @@ -380,6 +382,7 @@ private function getContainerBuilderProphecy() 'api_platform.router', 'api_platform.serializer.context_builder', 'api_platform.serializer.normalizer.item', + 'api_platform.subresource_data_provider', 'api_platform.swagger.action.ui', 'api_platform.swagger.command.swagger_command', 'api_platform.swagger.normalizer.documentation', @@ -393,6 +396,7 @@ private function getContainerBuilderProphecy() 'api_platform.action.delete_item' => 'api_platform.action.placeholder', 'api_platform.action.get_collection' => 'api_platform.action.placeholder', 'api_platform.action.get_item' => 'api_platform.action.placeholder', + 'api_platform.action.get_subresource' => 'api_platform.action.placeholder', 'api_platform.action.post_collection' => 'api_platform.action.placeholder', 'api_platform.action.put_item' => 'api_platform.action.placeholder', 'api_platform.metadata.property.metadata_factory' => 'api_platform.metadata.property.metadata_factory.xml', diff --git a/tests/Bridge/Symfony/Bundle/DependencyInjection/Compiler/DoctrineQueryExtensionPassTest.php b/tests/Bridge/Symfony/Bundle/DependencyInjection/Compiler/DoctrineQueryExtensionPassTest.php index 4f0add0e0cf..77d95fae8a1 100644 --- a/tests/Bridge/Symfony/Bundle/DependencyInjection/Compiler/DoctrineQueryExtensionPassTest.php +++ b/tests/Bridge/Symfony/Bundle/DependencyInjection/Compiler/DoctrineQueryExtensionPassTest.php @@ -38,12 +38,19 @@ public function testProcess() $itemDataProviderDefinitionProphecy->replaceArgument(3, Argument::type('array'))->shouldBeCalled(); $itemDataProviderDefinition = $itemDataProviderDefinitionProphecy->reveal(); + $subresourceDataProviderDefinitionProphecy = $this->prophesize(Definition::class); + $subresourceDataProviderDefinitionProphecy->replaceArgument(3, Argument::type('array'))->shouldBeCalled(); + $subresourceDataProviderDefinitionProphecy->replaceArgument(4, Argument::type('array'))->shouldBeCalled(); + $subresourceDataProviderDefinition = $subresourceDataProviderDefinitionProphecy->reveal(); + $containerBuilderProphecy = $this->prophesize(ContainerBuilder::class); $containerBuilderProphecy->hasDefinition('api_platform.doctrine.metadata_factory')->willReturn(true)->shouldBeCalled(); $containerBuilderProphecy->findTaggedServiceIds('api_platform.doctrine.orm.query_extension.collection')->willReturn(['foo' => [], 'bar' => ['priority' => 1]])->shouldBeCalled(); $containerBuilderProphecy->findTaggedServiceIds('api_platform.doctrine.orm.query_extension.item')->willReturn(['foo' => [], 'bar' => ['priority' => 1]])->shouldBeCalled(); + $containerBuilderProphecy->getDefinition('api_platform.doctrine.orm.collection_data_provider')->willReturn($collectionDataProviderDefinition)->shouldBeCalled(); $containerBuilderProphecy->getDefinition('api_platform.doctrine.orm.item_data_provider')->willReturn($itemDataProviderDefinition)->shouldBeCalled(); + $containerBuilderProphecy->getDefinition('api_platform.doctrine.orm.subresource_data_provider')->willReturn($subresourceDataProviderDefinition)->shouldBeCalled(); $containerBuilder = $containerBuilderProphecy->reveal(); $dataProviderPass->process($containerBuilder); diff --git a/tests/Bridge/Symfony/Routing/ApiLoaderTest.php b/tests/Bridge/Symfony/Routing/ApiLoaderTest.php index 031ce47bd96..f49fe421d02 100644 --- a/tests/Bridge/Symfony/Routing/ApiLoaderTest.php +++ b/tests/Bridge/Symfony/Routing/ApiLoaderTest.php @@ -14,6 +14,10 @@ namespace ApiPlatform\Core\Tests\Bridge\Symfony\Routing; use ApiPlatform\Core\Bridge\Symfony\Routing\ApiLoader; +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; @@ -156,9 +160,15 @@ private function getApiLoaderWithResourceMetadata(ResourceMetadata $resourceMeta $resourceNameCollectionFactoryProphecy = $this->prophesize(ResourceNameCollectionFactoryInterface::class); $resourceNameCollectionFactoryProphecy->create()->willReturn(new ResourceNameCollection([DummyEntity::class])); + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(DummyEntity::class)->willReturn(new PropertyNameCollection(['id'])); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(DummyEntity::class, 'id')->willReturn(new PropertyMetadata()); + $operationPathResolver = new CustomOperationPathResolver(new UnderscoreOperationPathResolver()); - $apiLoader = new ApiLoader($kernelProphecy->reveal(), $resourceNameCollectionFactoryProphecy->reveal(), $resourceMetadataFactoryProphecy->reveal(), $operationPathResolver, $containerProphecy->reveal(), ['jsonld' => ['application/ld+json']]); + $apiLoader = new ApiLoader($kernelProphecy->reveal(), $resourceNameCollectionFactoryProphecy->reveal(), $resourceMetadataFactoryProphecy->reveal(), $operationPathResolver, $containerProphecy->reveal(), ['jsonld' => ['application/ld+json']], [], $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal()); return $apiLoader; } diff --git a/tests/Bridge/Symfony/Routing/IriConverterTest.php b/tests/Bridge/Symfony/Routing/IriConverterTest.php index d2091cb041c..de84bf1179d 100644 --- a/tests/Bridge/Symfony/Routing/IriConverterTest.php +++ b/tests/Bridge/Symfony/Routing/IriConverterTest.php @@ -13,11 +13,14 @@ namespace ApiPlatform\Core\Tests\Bridge\Symfony\Routing; +use ApiPlatform\Core\Api\OperationType; +use ApiPlatform\Core\Api\UrlGeneratorInterface; use ApiPlatform\Core\Bridge\Symfony\Routing\IriConverter; use ApiPlatform\Core\Bridge\Symfony\Routing\RouteNameResolverInterface; use ApiPlatform\Core\DataProvider\ItemDataProviderInterface; use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy; use Symfony\Component\Routing\Exception\RouteNotFoundException; use Symfony\Component\Routing\RouterInterface; @@ -139,4 +142,166 @@ public function testGetItemFromIri() ); $converter->getItemFromIri('/users/3', ['fetch_data' => true]); } + + public function testGetIriFromResourceClass() + { + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + + $itemDataProviderProphecy = $this->prophesize(ItemDataProviderInterface::class); + + $routeNameResolverProphecy = $this->prophesize(RouteNameResolverInterface::class); + $routeNameResolverProphecy->getRouteName(Dummy::class, OperationType::COLLECTION)->willReturn('dummies'); + + $routerProphecy = $this->prophesize(RouterInterface::class); + $routerProphecy->generate('dummies', [], UrlGeneratorInterface::ABS_PATH)->willReturn('/dummies'); + + $converter = new IriConverter( + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $itemDataProviderProphecy->reveal(), + $routeNameResolverProphecy->reveal(), + $routerProphecy->reveal() + ); + + $this->assertEquals($converter->getIriFromResourceClass(Dummy::class), '/dummies'); + } + + /** + * @expectedException \ApiPlatform\Core\Exception\InvalidArgumentException + * @expectedExceptionMessage Unable to generate an IRI for "ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy" + */ + public function testNotAbleToGenerateGetIriFromResourceClass() + { + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + + $itemDataProviderProphecy = $this->prophesize(ItemDataProviderInterface::class); + + $routeNameResolverProphecy = $this->prophesize(RouteNameResolverInterface::class); + $routeNameResolverProphecy->getRouteName(Dummy::class, OperationType::COLLECTION)->willReturn('dummies'); + + $routerProphecy = $this->prophesize(RouterInterface::class); + $routerProphecy->generate('dummies', [], UrlGeneratorInterface::ABS_PATH)->willThrow(new RouteNotFoundException()); + + $converter = new IriConverter( + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $itemDataProviderProphecy->reveal(), + $routeNameResolverProphecy->reveal(), + $routerProphecy->reveal() + ); + + $converter->getIriFromResourceClass(Dummy::class); + } + + public function testGetSubresourceIriFromResourceClass() + { + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + + $itemDataProviderProphecy = $this->prophesize(ItemDataProviderInterface::class); + + $routeNameResolverProphecy = $this->prophesize(RouteNameResolverInterface::class); + $routeNameResolverProphecy->getRouteName(Dummy::class, OperationType::SUBRESOURCE)->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'); + + $converter = new IriConverter( + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $itemDataProviderProphecy->reveal(), + $routeNameResolverProphecy->reveal(), + $routerProphecy->reveal() + ); + + $this->assertEquals($converter->getSubresourceIriFromResourceClass(Dummy::class, ['id' => 1]), '/dummies/1/related_dummies'); + } + + /** + * @expectedException \ApiPlatform\Core\Exception\InvalidArgumentException + * @expectedExceptionMessage Unable to generate an IRI for "ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy" + */ + public function testNotAbleToGenerateGetSubresourceIriFromResourceClass() + { + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + + $itemDataProviderProphecy = $this->prophesize(ItemDataProviderInterface::class); + + $routeNameResolverProphecy = $this->prophesize(RouteNameResolverInterface::class); + $routeNameResolverProphecy->getRouteName(Dummy::class, OperationType::SUBRESOURCE)->willReturn('dummies'); + + $routerProphecy = $this->prophesize(RouterInterface::class); + $routerProphecy->generate('dummies', ['id' => 1], UrlGeneratorInterface::ABS_PATH)->willThrow(new RouteNotFoundException()); + + $converter = new IriConverter( + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $itemDataProviderProphecy->reveal(), + $routeNameResolverProphecy->reveal(), + $routerProphecy->reveal() + ); + + $converter->getSubresourceIriFromResourceClass(Dummy::class, ['id' => 1]); + } + + public function testGetItemIriFromResourceClass() + { + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + + $itemDataProviderProphecy = $this->prophesize(ItemDataProviderInterface::class); + + $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_PATH)->willReturn('/dummies/1'); + + $converter = new IriConverter( + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $itemDataProviderProphecy->reveal(), + $routeNameResolverProphecy->reveal(), + $routerProphecy->reveal() + ); + + $this->assertEquals($converter->getItemIriFromResourceClass(Dummy::class, ['id' => 1]), '/dummies/1'); + } + + /** + * @expectedException \ApiPlatform\Core\Exception\InvalidArgumentException + * @expectedExceptionMessage Unable to generate an IRI for "ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy" + */ + public function testNotAbleToGenerateGetItemIriFromResourceClass() + { + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + + $itemDataProviderProphecy = $this->prophesize(ItemDataProviderInterface::class); + + $routeNameResolverProphecy = $this->prophesize(RouteNameResolverInterface::class); + $routeNameResolverProphecy->getRouteName(Dummy::class, OperationType::ITEM)->willReturn('dummies'); + + $routerProphecy = $this->prophesize(RouterInterface::class); + $routerProphecy->generate('dummies', ['id' => 1], UrlGeneratorInterface::ABS_PATH)->willThrow(new RouteNotFoundException()); + + $converter = new IriConverter( + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $itemDataProviderProphecy->reveal(), + $routeNameResolverProphecy->reveal(), + $routerProphecy->reveal() + ); + + $converter->getItemIriFromResourceClass(Dummy::class, ['id' => 1]); + } } diff --git a/tests/Bridge/Symfony/Routing/RouteNameResolverTest.php b/tests/Bridge/Symfony/Routing/RouteNameResolverTest.php index a5836eea3cf..da377772294 100644 --- a/tests/Bridge/Symfony/Routing/RouteNameResolverTest.php +++ b/tests/Bridge/Symfony/Routing/RouteNameResolverTest.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Core\Tests\Bridge\Symfony\Routing; +use ApiPlatform\Core\Api\OperationType; use ApiPlatform\Core\Bridge\Symfony\Routing\RouteNameResolver; use ApiPlatform\Core\Bridge\Symfony\Routing\RouteNameResolverInterface; use Symfony\Component\Routing\Route; @@ -21,6 +22,7 @@ /** * @author Teoh Han Hui + * @group legacy */ class RouteNameResolverTest extends \PHPUnit_Framework_TestCase { @@ -112,4 +114,25 @@ public function testGetRouteNameForCollectionRoute() $this->assertSame('some_collection_route', $actual); } + + public function testGetRouteNameForSubresourceRoute() + { + $routeCollection = new RouteCollection(); + $routeCollection->add('some_subresource_route', new Route('/some/item/path/{id}', [ + '_api_resource_class' => 'AppBundle\Entity\User', + '_api_subresource_operation_name' => 'some_item_op', + ])); + $routeCollection->add('some_collection_route', new Route('/some/collection/path', [ + '_api_resource_class' => 'AppBundle\Entity\User', + '_api_collection_operation_name' => 'some_collection_op', + ])); + + $routerProphecy = $this->prophesize(RouterInterface::class); + $routerProphecy->getRouteCollection()->willReturn($routeCollection); + + $routeNameResolver = new RouteNameResolver($routerProphecy->reveal()); + $actual = $routeNameResolver->getRouteName('AppBundle\Entity\User', OperationType::SUBRESOURCE); + + $this->assertSame('some_subresource_route', $actual); + } } diff --git a/tests/DataProvider/ChainSubresourcedataProviderTest.php b/tests/DataProvider/ChainSubresourcedataProviderTest.php new file mode 100644 index 00000000000..3746e987a4c --- /dev/null +++ b/tests/DataProvider/ChainSubresourcedataProviderTest.php @@ -0,0 +1,56 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace ApiPlatform\Core\Tests\DataProvider; + +use ApiPlatform\Core\DataProvider\ChainSubresourceDataProvider; +use ApiPlatform\Core\DataProvider\SubresourceDataProviderInterface; +use ApiPlatform\Core\Exception\ResourceClassNotSupportedException; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy; + +/** + * Retrieves items from a persistence layer. + */ +class ChainSubresourcedataProviderTest extends \PHPUnit_Framework_TestCase +{ + public function testGetSubresource() + { + $dummy = new Dummy(); + $dummy->setName('Rosa'); + $dummy2 = new Dummy(); + $dummy2->setName('Parks'); + + $context = ['identifiers' => ['id' => Dummy::class], 'property' => 'relatedDummies']; + $firstDataProvider = $this->prophesize(SubresourceDataProviderInterface::class); + $firstDataProvider->getSubresource(Dummy::class, ['id' => 1], $context, 'get')->willReturn([$dummy, $dummy2])->willThrow(ResourceClassNotSupportedException::class); + + $secondDataProvider = $this->prophesize(SubresourceDataProviderInterface::class); + $secondDataProvider->getSubresource(Dummy::class, ['id' => 1], $context, 'get')->willReturn([$dummy, $dummy2]); + + $thirdDataProvider = $this->prophesize(SubresourceDataProviderInterface::class); + $thirdDataProvider->getSubresource(Dummy::class, ['id' => 1], $context, 'get')->willReturn([$dummy]); + + $chainSubresourceDataProvider = new ChainSubresourceDataProvider([$firstDataProvider->reveal(), $secondDataProvider->reveal(), $thirdDataProvider->reveal()]); + + $this->assertEquals([$dummy, $dummy2], $chainSubresourceDataProvider->getSubresource(Dummy::class, ['id' => 1], $context, 'get')); + } + + public function testGetCollectionExeptions() + { + $firstDataProvider = $this->prophesize(SubresourceDataProviderInterface::class); + $firstDataProvider->getSubresource('notfound', ['id' => 1], [], 'get')->willThrow(ResourceClassNotSupportedException::class); + + $chainItemDataProvider = new ChainSubresourceDataProvider([$firstDataProvider->reveal()]); + + $this->assertEquals('', $chainItemDataProvider->getSubresource('notfound', ['id' => 1], [], 'get')); + } +} diff --git a/tests/EventListener/ReadListenerTest.php b/tests/EventListener/ReadListenerTest.php index 4592dbcb3c3..e38abf3b9b0 100644 --- a/tests/EventListener/ReadListenerTest.php +++ b/tests/EventListener/ReadListenerTest.php @@ -15,6 +15,7 @@ use ApiPlatform\Core\DataProvider\CollectionDataProviderInterface; use ApiPlatform\Core\DataProvider\ItemDataProviderInterface; +use ApiPlatform\Core\DataProvider\SubresourceDataProviderInterface; use ApiPlatform\Core\EventListener\ReadListener; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Event\GetResponseEvent; @@ -32,10 +33,13 @@ public function testNotAnApiPlatformRequest() $itemDataProvider = $this->prophesize(ItemDataProviderInterface::class); $itemDataProvider->getItem()->shouldNotBeCalled(); + $subresourceDataProvider = $this->prophesize(SubresourceDataProviderInterface::class); + $subresourceDataProvider->getSubresource()->shouldNotBeCalled(); + $event = $this->prophesize(GetResponseEvent::class); $event->getRequest()->willReturn(new Request())->shouldBeCalled(); - $listener = new ReadListener($collectionDataProvider->reveal(), $itemDataProvider->reveal()); + $listener = new ReadListener($collectionDataProvider->reveal(), $itemDataProvider->reveal(), $subresourceDataProvider->reveal()); $listener->onKernelRequest($event->reveal()); } @@ -47,13 +51,16 @@ public function testDoNotCallWhenReceiveFlagIsFalse() $itemDataProvider = $this->prophesize(ItemDataProviderInterface::class); $itemDataProvider->getItem()->shouldNotBeCalled(); + $subresourceDataProvider = $this->prophesize(SubresourceDataProviderInterface::class); + $subresourceDataProvider->getSubresource()->shouldNotBeCalled(); + $request = new Request([], [], ['data' => new \stdClass(), '_api_resource_class' => 'Foo', '_api_collection_operation_name' => 'post', '_api_receive' => false]); $request->setMethod('PUT'); $event = $this->prophesize(GetResponseEvent::class); $event->getRequest()->willReturn($request)->shouldBeCalled(); - $listener = new ReadListener($collectionDataProvider->reveal(), $itemDataProvider->reveal()); + $listener = new ReadListener($collectionDataProvider->reveal(), $itemDataProvider->reveal(), $subresourceDataProvider->reveal()); $listener->onKernelRequest($event->reveal()); } @@ -65,13 +72,16 @@ public function testRetrieveCollectionPost() $itemDataProvider = $this->prophesize(ItemDataProviderInterface::class); $itemDataProvider->getItem()->shouldNotBeCalled(); + $subresourceDataProvider = $this->prophesize(SubresourceDataProviderInterface::class); + $subresourceDataProvider->getSubresource()->shouldNotBeCalled(); + $request = new Request([], [], ['_api_resource_class' => 'Foo', '_api_collection_operation_name' => 'post', '_api_format' => 'json', '_api_mime_type' => 'application/json'], [], [], [], '{}'); $request->setMethod(Request::METHOD_POST); $event = $this->prophesize(GetResponseEvent::class); $event->getRequest()->willReturn($request)->shouldBeCalled(); - $listener = new ReadListener($collectionDataProvider->reveal(), $itemDataProvider->reveal()); + $listener = new ReadListener($collectionDataProvider->reveal(), $itemDataProvider->reveal(), $subresourceDataProvider->reveal()); $listener->onKernelRequest($event->reveal()); $this->assertTrue($request->attributes->has('data')); @@ -86,13 +96,16 @@ public function testRetrieveCollectionGet() $itemDataProvider = $this->prophesize(ItemDataProviderInterface::class); $itemDataProvider->getItem()->shouldNotBeCalled(); + $subresourceDataProvider = $this->prophesize(SubresourceDataProviderInterface::class); + $subresourceDataProvider->getSubresource()->shouldNotBeCalled(); + $request = new Request([], [], ['_api_resource_class' => 'Foo', '_api_collection_operation_name' => 'get', '_api_format' => 'json', '_api_mime_type' => 'application/json']); $request->setMethod(Request::METHOD_GET); $event = $this->prophesize(GetResponseEvent::class); $event->getRequest()->willReturn($request)->shouldBeCalled(); - $listener = new ReadListener($collectionDataProvider->reveal(), $itemDataProvider->reveal()); + $listener = new ReadListener($collectionDataProvider->reveal(), $itemDataProvider->reveal(), $subresourceDataProvider->reveal()); $listener->onKernelRequest($event->reveal()); $this->assertSame([], $request->attributes->get('data')); @@ -107,13 +120,40 @@ public function testRetrieveItem() $itemDataProvider = $this->prophesize(ItemDataProviderInterface::class); $itemDataProvider->getItem('Foo', 1, 'get')->willReturn($data)->shouldBeCalled(); + $subresourceDataProvider = $this->prophesize(SubresourceDataProviderInterface::class); + $subresourceDataProvider->getSubresource()->shouldNotBeCalled(); + $request = new Request([], [], ['id' => 1, '_api_resource_class' => 'Foo', '_api_item_operation_name' => 'get', '_api_format' => 'json', '_api_mime_type' => 'application/json']); $request->setMethod(Request::METHOD_GET); $event = $this->prophesize(GetResponseEvent::class); $event->getRequest()->willReturn($request)->shouldBeCalled(); - $listener = new ReadListener($collectionDataProvider->reveal(), $itemDataProvider->reveal()); + $listener = new ReadListener($collectionDataProvider->reveal(), $itemDataProvider->reveal(), $subresourceDataProvider->reveal()); + $listener->onKernelRequest($event->reveal()); + + $this->assertSame($data, $request->attributes->get('data')); + } + + public function testRetrieveSubresource() + { + $collectionDataProvider = $this->prophesize(CollectionDataProviderInterface::class); + $collectionDataProvider->getCollection()->shouldNotBeCalled(); + + $itemDataProvider = $this->prophesize(ItemDataProviderInterface::class); + $itemDataProvider->getItem()->shouldNotBeCalled(); + + $data = [new \stdClass()]; + $subresourceDataProvider = $this->prophesize(SubresourceDataProviderInterface::class); + $subresourceDataProvider->getSubresource('Foo', ['id' => 1], ['identifiers' => [['id', 'Bar']], 'property' => 'bar'], 'get')->willReturn($data)->shouldBeCalled(); + + $request = new Request([], [], ['id' => 1, '_api_resource_class' => 'Foo', '_api_subresource_operation_name' => 'get', '_api_format' => 'json', '_api_mime_type' => 'application/json', '_api_subresource_context' => ['identifiers' => [['id', 'Bar']], 'property' => 'bar']]); + $request->setMethod(Request::METHOD_GET); + + $event = $this->prophesize(GetResponseEvent::class); + $event->getRequest()->willReturn($request)->shouldBeCalled(); + + $listener = new ReadListener($collectionDataProvider->reveal(), $itemDataProvider->reveal(), $subresourceDataProvider->reveal()); $listener->onKernelRequest($event->reveal()); $this->assertSame($data, $request->attributes->get('data')); @@ -129,13 +169,15 @@ public function testRetrieveItemNotFound() $itemDataProvider = $this->prophesize(ItemDataProviderInterface::class); $itemDataProvider->getItem('Foo', 22, 'get')->willReturn(null)->shouldBeCalled(); + $subresourceDataProvider = $this->prophesize(SubresourceDataProviderInterface::class); + $request = new Request([], [], ['id' => 22, '_api_resource_class' => 'Foo', '_api_item_operation_name' => 'get', '_api_format' => 'json', '_api_mime_type' => 'application/json']); $request->setMethod(Request::METHOD_GET); $event = $this->prophesize(GetResponseEvent::class); $event->getRequest()->willReturn($request)->shouldBeCalled(); - $listener = new ReadListener($collectionDataProvider->reveal(), $itemDataProvider->reveal()); + $listener = new ReadListener($collectionDataProvider->reveal(), $itemDataProvider->reveal(), $subresourceDataProvider->reveal()); $listener->onKernelRequest($event->reveal()); } } diff --git a/tests/Fixtures/TestBundle/Entity/Answer.php b/tests/Fixtures/TestBundle/Entity/Answer.php new file mode 100644 index 00000000000..406277dd20e --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/Answer.php @@ -0,0 +1,101 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Core\Annotation\ApiResource; +use Doctrine\ORM\Mapping as ORM; + +/** + * Answer. + * + * @ORM\Table(name="answer") + * @ORM\Entity + * @ApiResource + */ +class Answer +{ + /** + * @ORM\Column(name="id", type="integer") + * @ORM\Id + * @ORM\GeneratedValue(strategy="AUTO") + */ + private $id; + + /** + * @ORM\Column(name="content", type="string", nullable=false) + */ + private $content; + + /** + * @ORM\OneToOne(targetEntity="Question", mappedBy="answer") + */ + private $question; + + /** + * Get id. + * + * @return string + */ + public function getId() + { + return $this->id; + } + + /** + * Set content. + * + * @param string $content + * + * @return Answer + */ + public function setContent($content) + { + $this->content = $content; + + return $this; + } + + /** + * Get content. + * + * @return string + */ + public function getContent() + { + return $this->content; + } + + /** + * Set question. + * + * @param Question $question + * + * @return Answer + */ + public function setQuestion(Question $question = null) + { + $this->question = $question; + + return $this; + } + + /** + * Get question. + * + * @return Question + */ + public function getQuestion() + { + return $this->question; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/Container.php b/tests/Fixtures/TestBundle/Entity/Container.php new file mode 100644 index 00000000000..8c1ec2e2cf1 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/Container.php @@ -0,0 +1,73 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Core\Annotation\ApiProperty; +use ApiPlatform\Core\Annotation\ApiResource; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\ORM\Mapping as ORM; + +/** + * @ApiResource + * @ORM\Entity + */ +class Container +{ + /** + * @ORM\Id + * @ORM\Column(name="id", type="guid") + * + * @var string UUID + */ + private $id; + + /** + * @ApiProperty(subresource=true) + * @ORM\OneToMany( + * targetEntity="Node", + * mappedBy="container", + * indexBy="serial", + * fetch="LAZY", + * cascade={}, + * orphanRemoval=false + * ) + * @ORM\OrderBy({"serial"="ASC"}) + * + * @var ArrayCollection|Node[] + */ + private $nodes; + + /** + * @return string + */ + public function getId(): string + { + return $this->id; + } + + /** + * @param string id + */ + public function setId(string $id) + { + $this->id = $id; + } + + /** + * @return ArrayCollection|Node[] + */ + public function getNodes() + { + return $this->nodes; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/Dummy.php b/tests/Fixtures/TestBundle/Entity/Dummy.php index 25170b52f68..7811ac70fb8 100644 --- a/tests/Fixtures/TestBundle/Entity/Dummy.php +++ b/tests/Fixtures/TestBundle/Entity/Dummy.php @@ -125,6 +125,7 @@ class Dummy * @var ArrayCollection Several dummies * * @ORM\ManyToMany(targetEntity="RelatedDummy") + * @ApiProperty(subresource=true) */ public $relatedDummies; diff --git a/tests/Fixtures/TestBundle/Entity/Node.php b/tests/Fixtures/TestBundle/Entity/Node.php new file mode 100644 index 00000000000..935d97510ba --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/Node.php @@ -0,0 +1,64 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Core\Annotation\ApiResource; +use Doctrine\ORM\Mapping as ORM; + +/** + * @see https://github.com/api-platform/core/pull/904#issuecomment-294132077 + * @ApiResource + * @ORM\Entity + */ +class Node +{ + /** + * @ORM\Id + * @ORM\Column(name="serial", type="integer") + * + * @var int Node serial + */ + private $serial; + + /** + * @ORM\Id + * @ORM\ManyToOne(targetEntity="Container", fetch="LAZY") + * @ORM\JoinColumn(name="container_id", referencedColumnName="id", onDelete="RESTRICT") + * + * @var Container + */ + private $container; + + public function setContainer(Container $container) + { + $this->container = $container; + } + + public function getContainer(): Container + { + return $this->container; + } + + public function setSerial(int $serial) + { + $this->serial = $serial; + } + + /** + * @return int + */ + public function getSerial(): int + { + return $this->serial; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/Question.php b/tests/Fixtures/TestBundle/Entity/Question.php new file mode 100644 index 00000000000..5685aad2eb9 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/Question.php @@ -0,0 +1,102 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Core\Annotation\ApiProperty; +use ApiPlatform\Core\Annotation\ApiResource; +use Doctrine\ORM\Mapping as ORM; + +/** + * @ORM\Table(name="question") + * @ORM\Entity + * @ApiResource + */ +class Question +{ + /** + * @ORM\Column(name="id", type="integer") + * @ORM\Id + * @ORM\GeneratedValue(strategy="AUTO") + */ + private $id; + + /** + * @ORM\Column(name="content", type="string", nullable=true) + */ + private $content; + + /** + * @ORM\OneToOne(targetEntity="Answer", inversedBy="question") + * @ORM\JoinColumn(name="answer_id", referencedColumnName="id", unique=true) + * @ApiProperty(subresource=true) + */ + private $answer; + + /** + * Set content. + * + * @param string $content + * + * @return Question + */ + public function setContent($content) + { + $this->content = $content; + + return $this; + } + + /** + * Get content. + * + * @return string + */ + public function getContent() + { + return $this->content; + } + + /** + * Get id. + * + * @return string + */ + public function getId() + { + return $this->id; + } + + /** + * Set answer. + * + * @param Answer $answer + * + * @return Question + */ + public function setAnswer(Answer $answer = null) + { + $this->answer = $answer; + + return $this; + } + + /** + * Get answer. + * + * @return Answer + */ + public function getAnswer() + { + return $this->answer; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/RelatedDummy.php b/tests/Fixtures/TestBundle/Entity/RelatedDummy.php index fb024a4fdbb..89ea07c535a 100644 --- a/tests/Fixtures/TestBundle/Entity/RelatedDummy.php +++ b/tests/Fixtures/TestBundle/Entity/RelatedDummy.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity; +use ApiPlatform\Core\Annotation\ApiProperty; use ApiPlatform\Core\Annotation\ApiResource; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\ORM\Mapping as ORM; @@ -61,12 +62,14 @@ class RelatedDummy extends ParentDummy public $dummyDate; /** + * @ApiProperty(subresource=true) * @ORM\ManyToOne(targetEntity="ThirdLevel", cascade={"persist"}) * @Groups({"barcelona", "chicago", "friends"}) */ public $thirdLevel; /** + * @ApiProperty(subresource=true) * @ORM\OneToMany(targetEntity="RelatedToDummyFriend", cascade={"persist"}, mappedBy="relatedDummy") * @Groups({"fakemanytomany", "friends"}) */ diff --git a/tests/Fixtures/app/config/config.yml b/tests/Fixtures/app/config/config.yml index d36217c01bb..9a09d518186 100644 --- a/tests/Fixtures/app/config/config.yml +++ b/tests/Fixtures/app/config/config.yml @@ -139,10 +139,9 @@ services: app.related_dummy_resource.search_filter: parent: 'api_platform.doctrine.orm.search_filter' - arguments: [ { 'relatedToDummyFriend.dummyFriend': 'exact' } ] + arguments: [ { 'relatedToDummyFriend.dummyFriend': 'exact', 'name': 'partial' } ] tags: [ { name: 'api_platform.filter', id: 'related_dummy.friends' } ] - logger: class: Psr\Log\NullLogger diff --git a/tests/PathResolver/DashOperationPathResolverTest.php b/tests/PathResolver/DashOperationPathResolverTest.php index 71a3ce5543a..31083824f1a 100644 --- a/tests/PathResolver/DashOperationPathResolverTest.php +++ b/tests/PathResolver/DashOperationPathResolverTest.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Core\Tests\PathResolver; +use ApiPlatform\Core\Api\OperationType; use ApiPlatform\Core\PathResolver\DashOperationPathResolver; class DashOperationPathResolverTest extends \PHPUnit_Framework_TestCase @@ -21,13 +22,26 @@ public function testResolveCollectionOperationPath() { $dashOperationPathResolver = new DashOperationPathResolver(); - $this->assertSame('/short-names.{_format}', $dashOperationPathResolver->resolveOperationPath('ShortName', [], true)); + $this->assertSame('/short-names.{_format}', $dashOperationPathResolver->resolveOperationPath('ShortName', [], OperationType::COLLECTION)); } public function testResolveItemOperationPath() { $dashOperationPathResolver = new DashOperationPathResolver(); - $this->assertSame('/short-names/{id}.{_format}', $dashOperationPathResolver->resolveOperationPath('ShortName', [], false)); + $this->assertSame('/short-names/{id}.{_format}', $dashOperationPathResolver->resolveOperationPath('ShortName', [], OperationType::ITEM)); + } + + public function testResolveSubresourceOperationPath() + { + $dashOperationPathResolver = new DashOperationPathResolver(); + + $path = $dashOperationPathResolver->resolveOperationPath('ShortName', ['property' => 'relatedFoo', 'identifiers' => [['id', 'class']], 'collection' => true], OperationType::SUBRESOURCE); + + $this->assertSame('/short-names/{id}/related-foos.{_format}', $path); + + $next = $dashOperationPathResolver->resolveOperationPath($path, ['property' => 'bar', 'identifiers' => [['id', 'class'], ['relatedId', 'class']], 'collection' => false], OperationType::SUBRESOURCE); + + $this->assertSame('/short-names/{id}/related-foos/{relatedId}/bar.{_format}', $next); } } diff --git a/tests/PathResolver/UnderscoreOperationPathResolverTest.php b/tests/PathResolver/UnderscoreOperationPathResolverTest.php index ee725d71887..93ff32c2e64 100644 --- a/tests/PathResolver/UnderscoreOperationPathResolverTest.php +++ b/tests/PathResolver/UnderscoreOperationPathResolverTest.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Core\Tests\PathResolver; +use ApiPlatform\Core\Api\OperationType; use ApiPlatform\Core\PathResolver\UnderscoreOperationPathResolver; class UnderscoreOperationPathResolverTest extends \PHPUnit_Framework_TestCase @@ -21,6 +22,26 @@ public function testResolveCollectionOperationPath() { $underscoreOperationPathResolver = new UnderscoreOperationPathResolver(); - $this->assertSame('/short_names.{_format}', $underscoreOperationPathResolver->resolveOperationPath('ShortName', [], true)); + $this->assertSame('/short_names.{_format}', $underscoreOperationPathResolver->resolveOperationPath('ShortName', [], OperationType::COLLECTION)); + } + + public function testResolveItemOperationPath() + { + $underscoreOperationPathResolver = new UnderscoreOperationPathResolver(); + + $this->assertSame('/short_names/{id}.{_format}', $underscoreOperationPathResolver->resolveOperationPath('ShortName', [], OperationType::ITEM)); + } + + public function testResolveSubresourceOperationPath() + { + $dashOperationPathResolver = new UnderscoreOperationPathResolver(); + + $path = $dashOperationPathResolver->resolveOperationPath('ShortName', ['property' => 'relatedFoo', 'identifiers' => [['id', 'class']], 'collection' => true], OperationType::SUBRESOURCE); + + $this->assertSame('/short_names/{id}/related_foos.{_format}', $path); + + $next = $dashOperationPathResolver->resolveOperationPath($path, ['property' => 'bar', 'identifiers' => [['id', 'class'], ['relatedId', 'class']], 'collection' => false], OperationType::SUBRESOURCE); + + $this->assertSame('/short_names/{id}/related_foos/{relatedId}/bar.{_format}', $next); } } diff --git a/tests/Serializer/SerializerContextBuilderTest.php b/tests/Serializer/SerializerContextBuilderTest.php index 957b7e318d4..e2f216b4935 100644 --- a/tests/Serializer/SerializerContextBuilderTest.php +++ b/tests/Serializer/SerializerContextBuilderTest.php @@ -48,23 +48,27 @@ protected function setUp() public function testCreateFromRequest() { $request = new Request([], [], ['_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' => '']; + $expected = ['foo' => 'bar', 'item_operation_name' => 'get', 'resource_class' => 'Foo', 'request_uri' => '', 'operation_type' => 'item']; $this->assertEquals($expected, $this->builder->createFromRequest($request, true)); $request = new Request([], [], ['_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' => '']; + $expected = ['foo' => 'bar', 'collection_operation_name' => 'pot', 'resource_class' => 'Foo', 'request_uri' => '', 'operation_type' => 'collection']; $this->assertEquals($expected, $this->builder->createFromRequest($request, true)); $request = new Request([], [], ['_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' => '', 'api_allow_update' => false]; + $expected = ['bar' => 'baz', 'item_operation_name' => 'get', 'resource_class' => 'Foo', 'request_uri' => '', 'api_allow_update' => false, 'operation_type' => 'item']; $this->assertEquals($expected, $this->builder->createFromRequest($request, false)); $request = new Request([], [], ['_api_resource_class' => 'Foo', '_api_collection_operation_name' => 'post', '_api_format' => 'xml', '_api_mime_type' => 'text/xml'], [], [], ['REQUEST_METHOD' => 'POST']); - $expected = ['bar' => 'baz', 'collection_operation_name' => 'post', 'resource_class' => 'Foo', 'request_uri' => '', 'api_allow_update' => false]; + $expected = ['bar' => 'baz', 'collection_operation_name' => 'post', 'resource_class' => 'Foo', 'request_uri' => '', 'api_allow_update' => false, 'operation_type' => 'collection']; $this->assertEquals($expected, $this->builder->createFromRequest($request, false)); $request = new Request([], [], ['_api_resource_class' => 'Foo', '_api_collection_operation_name' => 'put', '_api_format' => 'xml', '_api_mime_type' => 'text/xml'], [], [], ['REQUEST_METHOD' => 'PUT']); - $expected = ['bar' => 'baz', 'collection_operation_name' => 'put', 'resource_class' => 'Foo', 'request_uri' => '', 'api_allow_update' => true]; + $expected = ['bar' => 'baz', 'collection_operation_name' => 'put', 'resource_class' => 'Foo', 'request_uri' => '', 'api_allow_update' => true, 'operation_type' => 'collection']; + $this->assertEquals($expected, $this->builder->createFromRequest($request, false)); + + $request = new Request([], [], ['_api_resource_class' => 'Foo', '_api_subresource_operation_name' => 'get', '_api_format' => 'xml', '_api_mime_type' => 'text/xml'], [], [], ['REQUEST_METHOD' => 'GET']); + $expected = ['bar' => 'baz', 'subresource_operation_name' => 'get', 'resource_class' => 'Foo', 'request_uri' => '', 'operation_type' => 'subresource', 'api_allow_update' => false]; $this->assertEquals($expected, $this->builder->createFromRequest($request, false)); } @@ -78,7 +82,7 @@ public function testThrowExceptionOnInvalidRequest() public function testReuseExistingAttributes() { - $expected = ['bar' => 'baz', 'item_operation_name' => 'get', 'resource_class' => 'Foo', 'request_uri' => '', 'api_allow_update' => false]; + $expected = ['bar' => 'baz', 'item_operation_name' => 'get', 'resource_class' => 'Foo', 'request_uri' => '', 'api_allow_update' => false, 'operation_type' => 'item']; $this->assertEquals($expected, $this->builder->createFromRequest(new Request(), false, ['resource_class' => 'Foo', 'item_operation_name' => 'get'])); } }