diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e771c7f130..ae02c297fc6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## v3.0.7 + +### Bug fixes + +* [27af3216f](https://github.com/api-platform/core/commit/27af3216f2beac654acb7881b52b3e2e29bf9078) fix(symfony): wire Symfony JsonEncoder if it exists (#5240) +* [31215c623](https://github.com/api-platform/core/commit/31215c62365c6b9095486c307d29837e53c0357a) ci: fix mongod startup (#5248) +* [55be4ca41](https://github.com/api-platform/core/commit/55be4ca41b6a97004d4be623d55bd5e7a3004b16) fix: get back return phpdoc on ProviderInterface +* [6d38cd941](https://github.com/api-platform/core/commit/6d38cd94140edd573ef9b09997204ef345360880) fix(metadata): include routePrefix in default operation name (#5203) (#5252) +* [b52161f](https://github.com/api-platform/core/commit/b52161f75cbfb8fd42b79db8b62e38747c84f089) perf(symfony): use default cache pool config in development environment (#5242) + ## v3.0.6 ### Bug fixes diff --git a/features/graphql/query.feature b/features/graphql/query.feature index 45f546066fc..4463a717b87 100644 --- a/features/graphql/query.feature +++ b/features/graphql/query.feature @@ -20,6 +20,54 @@ Feature: GraphQL query support And the JSON node "data.dummy.name" should be equal to "Dummy #1" And the JSON node "data.dummy.name_converted" should be equal to "Converted 1" + @createSchema + Scenario: Retrieve an item with different relations to the same resource + Given there are 2 multiRelationsDummy objects having each a manyToOneRelation, 2 manyToManyRelations and 3 oneToManyRelations + When I send the following GraphQL request: + """ + { + multiRelationsDummy(id: "/multi_relations_dummies/2") { + id + name + manyToOneRelation { + id + name + } + manyToManyRelations { + edges{ + node { + id + name + } + } + } + oneToManyRelations { + edges{ + node { + id + name + } + } + } + } + } + """ + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/json" + And the JSON node "data.multiRelationsDummy.id" should be equal to "/multi_relations_dummies/2" + And the JSON node "data.multiRelationsDummy.name" should be equal to "Dummy #2" + And the JSON node "data.multiRelationsDummy.manyToOneRelation.id" should not be null + And the JSON node "data.multiRelationsDummy.manyToOneRelation.name" should be equal to "RelatedManyToOneDummy #2" + And the JSON node "data.multiRelationsDummy.manyToManyRelations.edges" should have 2 element + And the JSON node "data.multiRelationsDummy.manyToManyRelations.edges[1].node.id" should not be null + And the JSON node "data.multiRelationsDummy.manyToManyRelations.edges[0].node.name" should be equal to "RelatedManyToManyDummy12" + And the JSON node "data.multiRelationsDummy.manyToManyRelations.edges[1].node.name" should be equal to "RelatedManyToManyDummy22" + And the JSON node "data.multiRelationsDummy.oneToManyRelations.edges" should have 3 element + And the JSON node "data.multiRelationsDummy.oneToManyRelations.edges[1].node.id" should not be null + And the JSON node "data.multiRelationsDummy.oneToManyRelations.edges[0].node.name" should be equal to "RelatedOneToManyDummy12" + And the JSON node "data.multiRelationsDummy.oneToManyRelations.edges[2].node.name" should be equal to "RelatedOneToManyDummy32" + @createSchema Scenario: Retrieve a Relay Node Given there are 2 dummy objects with relatedDummy diff --git a/features/hal/table_inheritance.feature b/features/hal/table_inheritance.feature new file mode 100644 index 00000000000..55ef27b7cad --- /dev/null +++ b/features/hal/table_inheritance.feature @@ -0,0 +1,141 @@ +Feature: Table inheritance + In order to use the api with Doctrine table inheritance + As a client software developer + I need to be able to create resources and fetch them on the upper entity + + Background: + Given I add "Accept" header equal to "application/hal+json" + And I add "Content-Type" header equal to "application/json" + + @createSchema + Scenario: Create a table inherited resource + And I send a "POST" request to "/dummy_table_inheritance_children" with body: + """ + { + "name": "foo", + "nickname": "bar" + } + """ + 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/hal+json; charset=utf-8" + And the JSON should be equal to: + """ + { + "_links": { + "self": { + "href": "/dummy_table_inheritance_children/1" + } + }, + "nickname": "bar", + "id": 1, + "name": "foo" + } + """ + + Scenario: Get the parent entity collection + When some dummy table inheritance data but not api resource child are created + When I send a "GET" request to "/dummy_table_inheritances" + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/hal+json; charset=utf-8" + And the JSON should be equal to: + """ + { + "_links": { + "self": { + "href": "/dummy_table_inheritances" + }, + "item": [ + { + "href": "/dummy_table_inheritance_children/1" + }, + { + "href": "/dummy_table_inheritances/2" + } + ] + }, + "totalItems": 2, + "itemsPerPage": 3, + "_embedded": { + "item": [ + { + "_links": { + "self": { + "href": "/dummy_table_inheritance_children/1" + } + }, + "nickname": "bar", + "id": 1, + "name": "foo" + }, + { + "_links": { + "self": { + "href": "/dummy_table_inheritances/2" + } + }, + "id": 2, + "name": "Foobarbaz inheritance" + } + ] + } + } + """ + + + Scenario: Get related entity with multiple inherited children types + And I send a "POST" request to "/dummy_table_inheritance_relateds" with body: + """ + { + "children": [ + "/dummy_table_inheritance_children/1", + "/dummy_table_inheritances/2" + ] + } + """ + 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/hal+json; charset=utf-8" + And the JSON should be equal to: + """ + { + "_links": { + "self": { + "href": "/dummy_table_inheritance_relateds/1" + }, + "children": [ + { + "href": "/dummy_table_inheritance_children/1" + }, + { + "href": "/dummy_table_inheritances/2" + } + ] + }, + "_embedded": { + "children": [ + { + "_links": { + "self": { + "href": "/dummy_table_inheritance_children/1" + } + }, + "nickname": "bar", + "id": 1, + "name": "foo" + }, + { + "_links": { + "self": { + "href": "/dummy_table_inheritances/2" + } + }, + "id": 2, + "name": "Foobarbaz inheritance" + } + ] + }, + "id": 1 + } + """ \ No newline at end of file diff --git a/src/Doctrine/Common/State/LinksHandlerTrait.php b/src/Doctrine/Common/State/LinksHandlerTrait.php index badd9d3df50..38f52beba68 100644 --- a/src/Doctrine/Common/State/LinksHandlerTrait.php +++ b/src/Doctrine/Common/State/LinksHandlerTrait.php @@ -34,14 +34,20 @@ private function getLinks(string $resourceClass, Operation $operation, array $co return $links; } - $newLinks = []; + $newLink = null; + $linkProperty = $context['linkProperty'] ?? null; foreach ($links as $link) { - if ($linkClass === $link->getFromClass()) { - $newLinks[] = $link; + if ($linkClass === $link->getFromClass() && $linkProperty === $link->getFromProperty()) { + $newLink = $link; + break; } } + if ($newLink) { + return [$newLink]; + } + // Using GraphQL, it's possible that we won't find a GraphQL Operation of the same type (e.g. it is disabled). try { $resourceMetadataCollection = $this->resourceMetadataCollectionFactory->create($linkClass); @@ -62,16 +68,17 @@ private function getLinks(string $resourceClass, Operation $operation, array $co } foreach ($this->getOperationLinks($linkedOperation ?? null) as $link) { - if ($resourceClass === $link->getToClass()) { - $newLinks[] = $link; + if ($resourceClass === $link->getToClass() && $linkProperty === $link->getFromProperty()) { + $newLink = $link; + break; } } - if (!$newLinks) { + if (!$newLink) { throw new RuntimeException(sprintf('The class "%s" cannot be retrieved from "%s".', $resourceClass, $linkClass)); } - return $newLinks; + return [$newLink]; } private function getIdentifierValue(array &$identifiers, string $name = null): mixed diff --git a/src/GraphQl/Resolver/Stage/ReadStage.php b/src/GraphQl/Resolver/Stage/ReadStage.php index b5c7d119030..527b7aea493 100644 --- a/src/GraphQl/Resolver/Stage/ReadStage.php +++ b/src/GraphQl/Resolver/Stage/ReadStage.php @@ -83,6 +83,7 @@ public function __invoke(?string $resourceClass, ?string $rootClass, Operation $ if (isset($source[$info->fieldName], $source[ItemNormalizer::ITEM_IDENTIFIERS_KEY], $source[ItemNormalizer::ITEM_RESOURCE_CLASS_KEY])) { $uriVariables = $source[ItemNormalizer::ITEM_IDENTIFIERS_KEY]; $normalizationContext['linkClass'] = $source[ItemNormalizer::ITEM_RESOURCE_CLASS_KEY]; + $normalizationContext['linkProperty'] = $info->fieldName; } return $this->provider->provide($operation, $uriVariables, $normalizationContext); diff --git a/src/Metadata/Resource/Factory/ExtractorResourceMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/ExtractorResourceMetadataCollectionFactory.php index abc365f2ec2..2bf245a10bf 100644 --- a/src/Metadata/Resource/Factory/ExtractorResourceMetadataCollectionFactory.php +++ b/src/Metadata/Resource/Factory/ExtractorResourceMetadataCollectionFactory.php @@ -31,7 +31,7 @@ final class ExtractorResourceMetadataCollectionFactory implements ResourceMetada { use OperationDefaultsTrait; - public function __construct(private readonly ResourceExtractorInterface $extractor, private readonly ?ResourceMetadataCollectionFactoryInterface $decorated = null, array $defaults = [], LoggerInterface $logger = null) + public function __construct(private readonly ResourceExtractorInterface $extractor, private readonly ?ResourceMetadataCollectionFactoryInterface $decorated = null, array $defaults = [], LoggerInterface $logger = null, private readonly bool $graphQlEnabled = false) { $this->logger = $logger ?? new NullLogger(); $this->defaults = $defaults; @@ -85,7 +85,9 @@ private function buildResources(array $nodes, string $resourceClass): array } } - $resource = $this->addGraphQlOperations($node['graphQlOperations'] ?? null, $resource); + if ($this->graphQlEnabled) { + $resource = $this->addGraphQlOperations($node['graphQlOperations'] ?? null, $resource); + } $resources[] = $this->addOperations($node['operations'] ?? null, $resource); } diff --git a/src/Metadata/Resource/Factory/LinkResourceMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/LinkResourceMetadataCollectionFactory.php index e8e7b9990f0..d2f3afcae72 100644 --- a/src/Metadata/Resource/Factory/LinkResourceMetadataCollectionFactory.php +++ b/src/Metadata/Resource/Factory/LinkResourceMetadataCollectionFactory.php @@ -68,16 +68,16 @@ private function mergeLinks(array $links, array $toMergeLinks): array { $classLinks = []; foreach ($links as $link) { - $classLinks[$link->getToClass()] = $link; + $classLinks[$link->getToClass().'#'.$link->getFromProperty()] = $link; } foreach ($toMergeLinks as $link) { - if (isset($classLinks[$link->getToClass()])) { - $classLinks[$link->getToClass()] = $classLinks[$link->getToClass()]->withLink($link); + if (null !== $prevLink = $classLinks[$link->getToClass().'#'.$link->getFromProperty()] ?? null) { + $classLinks[$link->getToClass().'#'.$link->getFromProperty()] = $prevLink->withLink($link); continue; } - $classLinks[$link->getToClass()] = $link; + $classLinks[$link->getToClass().'#'.$link->getFromProperty()] = $link; } return array_values($classLinks); diff --git a/src/Metadata/Resource/Factory/OperationDefaultsTrait.php b/src/Metadata/Resource/Factory/OperationDefaultsTrait.php index d320bd41577..31214c83d2c 100644 --- a/src/Metadata/Resource/Factory/OperationDefaultsTrait.php +++ b/src/Metadata/Resource/Factory/OperationDefaultsTrait.php @@ -187,16 +187,28 @@ private function getOperationWithDefaults(ApiResource $resource, Operation $oper $operation = $operation->withName($operation->getRouteName()); } - $operationName = $operation->getName() ?? sprintf( - '_api_%s_%s%s', - $operation->getUriTemplate() ?: $operation->getShortName(), - strtolower($operation->getMethod() ?? HttpOperation::METHOD_GET), - $operation instanceof CollectionOperationInterface ? '_collection' : '', - ); + $path = ($operation->getRoutePrefix() ?? '').($operation->getUriTemplate() ?? ''); + $operationName = $operation->getName() ?? $this->getDefaultOperationName($operation, $resource->getClass()); return [ $operationName, $operation, ]; } + + private function getDefaultShortname(string $resourceClass): string + { + return (false !== $pos = strrpos($resourceClass, '\\')) ? substr($resourceClass, $pos + 1) : $resourceClass; + } + + private function getDefaultOperationName(HttpOperation $operation, string $resourceClass): string + { + $path = ($operation->getRoutePrefix() ?? '').($operation->getUriTemplate() ?? ''); + + return sprintf( + '_api_%s_%s%s', + $path ?: ($operation->getShortName() ?? $this->getDefaultShortname($resourceClass)), + strtolower($operation->getMethod() ?? HttpOperation::METHOD_GET), + $operation instanceof CollectionOperationInterface ? '_collection' : ''); + } } diff --git a/src/Metadata/Resource/Factory/OperationNameResourceMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/OperationNameResourceMetadataCollectionFactory.php index 120c8a1c822..9127deaec85 100644 --- a/src/Metadata/Resource/Factory/OperationNameResourceMetadataCollectionFactory.php +++ b/src/Metadata/Resource/Factory/OperationNameResourceMetadataCollectionFactory.php @@ -13,8 +13,6 @@ namespace ApiPlatform\Metadata\Resource\Factory; -use ApiPlatform\Metadata\CollectionOperationInterface; -use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; /** @@ -24,6 +22,8 @@ */ final class OperationNameResourceMetadataCollectionFactory implements ResourceMetadataCollectionFactoryInterface { + use OperationDefaultsTrait; + public function __construct(private readonly ?ResourceMetadataCollectionFactoryInterface $decorated = null) { } @@ -52,8 +52,7 @@ public function create(string $resourceClass): ResourceMetadataCollection continue; } - $path = ($operation->getRoutePrefix() ?? '').($operation->getUriTemplate() ?? ''); - $newOperationName = sprintf('_api_%s_%s%s', $path ?: ($operation->getShortName() ?? $this->getDefaultShortname($resourceClass)), strtolower($operation->getMethod() ?? HttpOperation::METHOD_GET), $operation instanceof CollectionOperationInterface ? '_collection' : ''); + $newOperationName = $this->getDefaultOperationName($operation, $resourceClass); $operations->remove($operationName)->add($newOperationName, $operation->withName($newOperationName)); } @@ -62,9 +61,4 @@ public function create(string $resourceClass): ResourceMetadataCollection return $resourceMetadataCollection; } - - private function getDefaultShortname(string $resourceClass): string - { - return (false !== $pos = strrpos($resourceClass, '\\')) ? substr($resourceClass, $pos + 1) : $resourceClass; - } } diff --git a/src/Metadata/Resource/Factory/UriTemplateResourceMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/UriTemplateResourceMetadataCollectionFactory.php index 0c62bf81693..41f8064ae7a 100644 --- a/src/Metadata/Resource/Factory/UriTemplateResourceMetadataCollectionFactory.php +++ b/src/Metadata/Resource/Factory/UriTemplateResourceMetadataCollectionFactory.php @@ -27,6 +27,8 @@ */ final class UriTemplateResourceMetadataCollectionFactory implements ResourceMetadataCollectionFactoryInterface { + use OperationDefaultsTrait; + private $triggerLegacyFormatOnce = []; public function __construct(private readonly LinkFactoryInterface $linkFactory, private readonly PathSegmentNameGeneratorInterface $pathSegmentNameGenerator, private readonly ?ResourceMetadataCollectionFactoryInterface $decorated = null) @@ -78,12 +80,12 @@ public function create(string $resourceClass): ResourceMetadataCollection } $operation = $operation->withUriTemplate($this->generateUriTemplate($operation)); - $operationName = $operation->getName() ?: sprintf('_api_%s_%s%s', $operation->getUriTemplate(), strtolower($operation->getMethod() ?? HttpOperation::METHOD_GET), $operation instanceof CollectionOperationInterface ? '_collection' : ''); + if (!$operation->getName()) { - $operation = $operation->withName($operationName); + $operation = $operation->withName($this->getDefaultOperationName($operation, $resourceClass)); } - $operations->add($operationName, $operation); + $operations->add($operation->getName(), $operation); } $resource = $resource->withOperations($operations->sort()); diff --git a/src/Serializer/AbstractItemNormalizer.php b/src/Serializer/AbstractItemNormalizer.php index 52c79f13c1f..752f9065fd4 100644 --- a/src/Serializer/AbstractItemNormalizer.php +++ b/src/Serializer/AbstractItemNormalizer.php @@ -569,11 +569,7 @@ protected function getAttributeValue(object $object, string $attribute, string $ $resourceClass = $this->resourceClassResolver->getResourceClass($attributeValue, $className); $childContext = $this->createChildContext($context, $attribute, $format); - $childContext['resource_class'] = $resourceClass; - if ($this->resourceMetadataCollectionFactory) { - $childContext['operation'] = $this->resourceMetadataCollectionFactory->create($resourceClass)->getOperation(); - } - unset($childContext['iri'], $childContext['uri_variables']); + unset($childContext['iri'], $childContext['uri_variables'], $childContext['resource_class'], $childContext['operation']); return $this->normalizeCollectionOfRelations($propertyMetadata, $attributeValue, $resourceClass, $format, $childContext); } @@ -628,6 +624,13 @@ protected function normalizeCollectionOfRelations(ApiProperty $propertyMetadata, throw new UnexpectedValueException('Unexpected non-object element in to-many relation.'); } + // update context, if concrete object class deviates from general relation class (e.g. in case of polymorphic resources) + $objResourceClass = $this->resourceClassResolver->getResourceClass($obj, $resourceClass); + $context['resource_class'] = $objResourceClass; + if ($this->resourceMetadataCollectionFactory) { + $context['operation'] = $this->resourceMetadataCollectionFactory->create($objResourceClass)->getOperation(); + } + $value[$index] = $this->normalizeRelation($propertyMetadata, $obj, $resourceClass, $format, $context); } diff --git a/src/State/ProviderInterface.php b/src/State/ProviderInterface.php index ea72049c7f7..8323433b074 100644 --- a/src/State/ProviderInterface.php +++ b/src/State/ProviderInterface.php @@ -26,6 +26,8 @@ interface ProviderInterface { /** * Provides data. + * + * @return T|Pagination\PartialPaginatorInterface|iterable|null */ public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null; } diff --git a/src/Symfony/Bundle/Resources/config/metadata/resource.xml b/src/Symfony/Bundle/Resources/config/metadata/resource.xml index d625eb8ceaa..d30f7785f82 100644 --- a/src/Symfony/Bundle/Resources/config/metadata/resource.xml +++ b/src/Symfony/Bundle/Resources/config/metadata/resource.xml @@ -21,6 +21,7 @@ %api_platform.defaults% + %api_platform.graphql.enabled% diff --git a/tests/Behat/DoctrineContext.php b/tests/Behat/DoctrineContext.php index 37aaa67205b..7237a47359e 100644 --- a/tests/Behat/DoctrineContext.php +++ b/tests/Behat/DoctrineContext.php @@ -63,6 +63,8 @@ use ApiPlatform\Tests\Fixtures\TestBundle\Document\InitializeInput as InitializeInputDocument; use ApiPlatform\Tests\Fixtures\TestBundle\Document\IriOnlyDummy as IriOnlyDummyDocument; use ApiPlatform\Tests\Fixtures\TestBundle\Document\MaxDepthDummy as MaxDepthDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\MultiRelationsDummy as MultiRelationsDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\MultiRelationsRelatedDummy as MultiRelationsRelatedDummyDocument; use ApiPlatform\Tests\Fixtures\TestBundle\Document\NetworkPathDummy as NetworkPathDummyDocument; use ApiPlatform\Tests\Fixtures\TestBundle\Document\NetworkPathRelationDummy as NetworkPathRelationDummyDocument; use ApiPlatform\Tests\Fixtures\TestBundle\Document\Order as OrderDocument; @@ -138,6 +140,8 @@ use ApiPlatform\Tests\Fixtures\TestBundle\Entity\InternalUser; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\IriOnlyDummy; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MaxDepthDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MultiRelationsDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MultiRelationsRelatedDummy; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\NetworkPathDummy; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\NetworkPathRelationDummy; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Order; @@ -759,6 +763,42 @@ public function thereAreDummyObjectsWithRelatedDummies(int $nb, int $nbrelated): $this->manager->flush(); } + /** + * @Given there are :nb multiRelationsDummy objects having each a manyToOneRelation, :nbmtmr manyToManyRelations and :nbotmr oneToManyRelations + */ + public function thereAreMultiRelationsDummyObjectsHavingEachAManyToOneRelationManyToManyRelationsAndOneToManyRelations(int $nb, int $nbmtmr, int $nbotmr): void + { + for ($i = 1; $i <= $nb; ++$i) { + $relatedDummy = $this->buildMultiRelationsRelatedDummy(); + $relatedDummy->name = 'RelatedManyToOneDummy #'.$i; + + $dummy = $this->buildMultiRelationsDummy(); + $dummy->name = 'Dummy #'.$i; + $dummy->setManyToOneRelation($relatedDummy); + + for ($j = 1; $j <= $nbmtmr; ++$j) { + $manyToManyItem = $this->buildMultiRelationsRelatedDummy(); + $manyToManyItem->name = 'RelatedManyToManyDummy'.$j.$i; + $this->manager->persist($manyToManyItem); + + $dummy->addManyToManyRelation($manyToManyItem); + } + + for ($j = 1; $j <= $nbotmr; ++$j) { + $oneToManyItem = $this->buildMultiRelationsRelatedDummy(); + $oneToManyItem->name = 'RelatedOneToManyDummy'.$j.$i; + $oneToManyItem->setOneToManyRelation($dummy); + $this->manager->persist($oneToManyItem); + + $dummy->addOneToManyRelation($oneToManyItem); + } + + $this->manager->persist($relatedDummy); + $this->manager->persist($dummy); + } + $this->manager->flush(); + } + /** * @Given there are :nb dummy objects with dummyDate * @Given there is :nb dummy object with dummyDate @@ -2300,4 +2340,14 @@ private function buildPayment(string $amount): Payment|PaymentDocument { return $this->isOrm() ? new Payment($amount) : new PaymentDocument($amount); } + + private function buildMultiRelationsDummy(): MultiRelationsDummy|MultiRelationsDummyDocument + { + return $this->isOrm() ? new MultiRelationsDummy() : new MultiRelationsDummyDocument(); + } + + private function buildMultiRelationsRelatedDummy(): MultiRelationsRelatedDummy|MultiRelationsRelatedDummyDocument + { + return $this->isOrm() ? new MultiRelationsRelatedDummy() : new MultiRelationsRelatedDummyDocument(); + } } diff --git a/tests/Fixtures/TestBundle/Document/MultiRelationsDummy.php b/tests/Fixtures/TestBundle/Document/MultiRelationsDummy.php new file mode 100644 index 00000000000..dd70130d1a9 --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/MultiRelationsDummy.php @@ -0,0 +1,79 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Document; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GraphQl\Query; +use ApiPlatform\Metadata\GraphQl\QueryCollection; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +/** + * Dummy using different kind of relations to the same resource. + * + * @author Thomas Helmrich + */ +#[ApiResource(graphQlOperations: [new QueryCollection(), new Query()])] +#[ODM\Document] +class MultiRelationsDummy +{ + #[ODM\Id(strategy: 'INCREMENT', type: 'int')] + private ?int $id = null; + + #[ODM\Field(type: 'string')] + public string $name; + + #[ODM\ReferenceOne(targetDocument: MultiRelationsRelatedDummy::class, storeAs: 'id', nullable: true)] + public ?MultiRelationsRelatedDummy $manyToOneRelation = null; + + /** @var Collection */ + #[ODM\ReferenceMany(targetDocument: MultiRelationsRelatedDummy::class, storeAs: 'id', nullable: true)] + public Collection $manyToManyRelations; + + /** @var Collection */ + #[ODM\ReferenceMany(targetDocument: MultiRelationsRelatedDummy::class, mappedBy: 'oneToManyRelation', storeAs: 'id')] + public Collection $oneToManyRelations; + + public function __construct() + { + $this->manyToManyRelations = new ArrayCollection(); + $this->oneToManyRelations = new ArrayCollection(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getManyToOneRelation(): ?MultiRelationsRelatedDummy + { + return $this->manyToOneRelation; + } + + public function setManyToOneRelation(?MultiRelationsRelatedDummy $relatedMultiUsedDummy): void + { + $this->manyToOneRelation = $relatedMultiUsedDummy; + } + + public function addManyToManyRelation(MultiRelationsRelatedDummy $relatedMultiUsedDummy): void + { + $this->manyToManyRelations->add($relatedMultiUsedDummy); + } + + public function addOneToManyRelation(MultiRelationsRelatedDummy $relatedMultiUsedDummy): void + { + $this->oneToManyRelations->add($relatedMultiUsedDummy); + } +} diff --git a/tests/Fixtures/TestBundle/Document/MultiRelationsRelatedDummy.php b/tests/Fixtures/TestBundle/Document/MultiRelationsRelatedDummy.php new file mode 100644 index 00000000000..8794d5f4867 --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/MultiRelationsRelatedDummy.php @@ -0,0 +1,53 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Document; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GraphQl\Query; +use ApiPlatform\Metadata\GraphQl\QueryCollection; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +/** + * Dummy used in different kind of relations in the same resource. + * + * @author Thomas Helmrich + */ +#[ApiResource(graphQlOperations: [new QueryCollection(), new Query()])] +#[ODM\Document] +class MultiRelationsRelatedDummy +{ + #[ODM\Id(strategy: 'INCREMENT', type: 'int')] + private ?int $id = null; + + #[ODM\Field(type: 'string', nullable: true)] + public ?string $name; + + #[ODM\ReferenceOne(targetDocument: MultiRelationsDummy::class, inversedBy: 'oneToManyRelations', nullable: true, storeAs: 'id')] + private ?MultiRelationsDummy $oneToManyRelation; + + public function getId(): ?int + { + return $this->id; + } + + public function getOneToManyRelation(): ?MultiRelationsDummy + { + return $this->oneToManyRelation; + } + + public function setOneToManyRelation(?MultiRelationsDummy $oneToManyRelation): void + { + $this->oneToManyRelation = $oneToManyRelation; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/MultiRelationsDummy.php b/tests/Fixtures/TestBundle/Entity/MultiRelationsDummy.php new file mode 100644 index 00000000000..14e9d8a0f8b --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/MultiRelationsDummy.php @@ -0,0 +1,81 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GraphQl\Query; +use ApiPlatform\Metadata\GraphQl\QueryCollection; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; +use Doctrine\ORM\Mapping as ORM; + +/** + * Dummy using different kind of relations to the same resource. + * + * @author Thomas Helmrich + */ +#[ApiResource(graphQlOperations: [new QueryCollection(), new Query()])] +#[ORM\Entity] +class MultiRelationsDummy +{ + #[ORM\Column(type: 'integer', nullable: true)] + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'AUTO')] + private ?int $id = null; + + #[ORM\Column] + public string $name; + + #[ORM\ManyToOne(targetEntity: MultiRelationsRelatedDummy::class)] + public ?MultiRelationsRelatedDummy $manyToOneRelation = null; + + /** @var Collection */ + #[ORM\ManyToMany(targetEntity: MultiRelationsRelatedDummy::class)] + public Collection $manyToManyRelations; + + /** @var Collection */ + #[ORM\OneToMany(targetEntity: MultiRelationsRelatedDummy::class, mappedBy: 'oneToManyRelation')] + public Collection $oneToManyRelations; + + public function __construct() + { + $this->manyToManyRelations = new ArrayCollection(); + $this->oneToManyRelations = new ArrayCollection(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getManyToOneRelation(): ?MultiRelationsRelatedDummy + { + return $this->manyToOneRelation; + } + + public function setManyToOneRelation(?MultiRelationsRelatedDummy $relatedMultiUsedDummy): void + { + $this->manyToOneRelation = $relatedMultiUsedDummy; + } + + public function addManyToManyRelation(MultiRelationsRelatedDummy $relatedMultiUsedDummy): void + { + $this->manyToManyRelations->add($relatedMultiUsedDummy); + } + + public function addOneToManyRelation(MultiRelationsRelatedDummy $relatedMultiUsedDummy): void + { + $this->oneToManyRelations->add($relatedMultiUsedDummy); + } +} diff --git a/tests/Fixtures/TestBundle/Entity/MultiRelationsRelatedDummy.php b/tests/Fixtures/TestBundle/Entity/MultiRelationsRelatedDummy.php new file mode 100644 index 00000000000..ba462ce4b56 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/MultiRelationsRelatedDummy.php @@ -0,0 +1,55 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GraphQl\Query; +use ApiPlatform\Metadata\GraphQl\QueryCollection; +use Doctrine\ORM\Mapping as ORM; + +/** + * Dummy used in different kind of relations in the same resource. + * + * @author Thomas Helmrich + */ +#[ApiResource(graphQlOperations: [new QueryCollection(), new Query()])] +#[ORM\Entity] +class MultiRelationsRelatedDummy +{ + #[ORM\Column(type: 'integer')] + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'AUTO')] + private ?int $id = null; + + #[ORM\Column(nullable: true)] + public ?string $name; + + #[ORM\ManyToOne(targetEntity: MultiRelationsDummy::class, inversedBy: 'oneToManyRelations')] + private ?MultiRelationsDummy $oneToManyRelation = null; + + public function getId(): ?int + { + return $this->id; + } + + public function getOneToManyRelation(): ?MultiRelationsDummy + { + return $this->oneToManyRelation; + } + + public function setOneToManyRelation(?MultiRelationsDummy $oneToManyRelation): void + { + $this->oneToManyRelation = $oneToManyRelation; + } +} diff --git a/tests/GraphQl/Resolver/Stage/ReadStageTest.php b/tests/GraphQl/Resolver/Stage/ReadStageTest.php index 95c53841691..1022cf8347a 100644 --- a/tests/GraphQl/Resolver/Stage/ReadStageTest.php +++ b/tests/GraphQl/Resolver/Stage/ReadStageTest.php @@ -199,7 +199,7 @@ public function testApplyCollection(array $args, ?string $rootClass, ?array $sou $this->serializerContextBuilderProphecy->create($resourceClass, $operation, $context, true)->shouldBeCalled()->willReturn($normalizationContext); $this->providerProphecy->provide($operation, [], $normalizationContext + ['filters' => $expectedFilters])->willReturn([]); - $this->providerProphecy->provide($operation, ['id' => 3], $normalizationContext + ['filters' => $expectedFilters, 'linkClass' => 'myResource'])->willReturn(['resource']); + $this->providerProphecy->provide($operation, ['id' => 3], $normalizationContext + ['filters' => $expectedFilters, 'linkClass' => 'myResource', 'linkProperty' => 'resource'])->willReturn(['resource']); $result = ($this->readStage)($resourceClass, $rootClass, $operation, $context); diff --git a/tests/Metadata/Extractor/ResourceMetadataCompatibilityTest.php b/tests/Metadata/Extractor/ResourceMetadataCompatibilityTest.php index 77e84dc5134..1838a1db1c2 100644 --- a/tests/Metadata/Extractor/ResourceMetadataCompatibilityTest.php +++ b/tests/Metadata/Extractor/ResourceMetadataCompatibilityTest.php @@ -14,7 +14,6 @@ namespace ApiPlatform\Tests\Metadata\Extractor; use ApiPlatform\Metadata\ApiResource; -use ApiPlatform\Metadata\CollectionOperationInterface; use ApiPlatform\Metadata\Delete; use ApiPlatform\Metadata\Extractor\XmlResourceExtractor; use ApiPlatform\Metadata\Extractor\YamlResourceExtractor; @@ -443,7 +442,7 @@ public function testValidMetadata(string $extractorClass, ResourceAdapterInterfa try { $extractor = new $extractorClass($adapter(self::RESOURCE_CLASS, $parameters, self::FIXTURES)); - $factory = new ExtractorResourceMetadataCollectionFactory($extractor, null, self::DEFAULTS); + $factory = new ExtractorResourceMetadataCollectionFactory($extractor, null, self::DEFAULTS, null, true); $collection = $factory->create(self::RESOURCE_CLASS); } catch (\Exception $exception) { throw new AssertionFailedError('Failed asserting that the schema is valid according to '.ApiResource::class, 0, $exception); @@ -474,7 +473,7 @@ private function buildApiResources(): array // Build default operations $operations = []; foreach ([new Get(), new GetCollection(), new Post(), new Put(), new Patch(), new Delete()] as $operation) { - $operationName = sprintf('_api_%s_%s%s', $resource->getShortName(), strtolower($operation->getMethod()), $operation instanceof CollectionOperationInterface ? '_collection' : ''); + $operationName = $this->getDefaultOperationName($operation, self::RESOURCE_CLASS); [$name, $operation] = $this->getOperationWithDefaults($resource, $operation); $operations[$name] = $operation; } @@ -572,7 +571,7 @@ private function withOperations(array $values, ?array $fixtures): Operations throw new \RuntimeException(sprintf('Unknown Operation parameter "%s".', $parameter)); } - $operationName = $operation->getName() ?? sprintf('_api_%s_%s%s', $operation->getUriTemplate() ?: $operation->getShortName(), strtolower($operation->getMethod()), $operation instanceof CollectionOperationInterface ? '_collection' : ''); + $operationName = $operation->getName() ?? $this->getDefaultOperationName($operation, self::RESOURCE_CLASS); $operations[$operationName] = $operation; } diff --git a/tests/Metadata/Resource/Factory/LinkResourceMetadataCollectionFactoryTest.php b/tests/Metadata/Resource/Factory/LinkResourceMetadataCollectionFactoryTest.php index 174dcb1442b..790bc52fe4d 100644 --- a/tests/Metadata/Resource/Factory/LinkResourceMetadataCollectionFactoryTest.php +++ b/tests/Metadata/Resource/Factory/LinkResourceMetadataCollectionFactoryTest.php @@ -84,6 +84,7 @@ class: AttributeResource::class, class: AttributeResource::class, graphQlOperations: [ 'item_query' => (new Query(shortName: 'AttributeResource', class: AttributeResource::class))->withLinks([ + (new Link())->withFromProperty('foo')->withFromClass(AttributeResource::class)->withToClass(Dummy::class)->withIdentifiers(['id']), (new Link())->withFromProperty('foo2')->withFromClass(AttributeResource::class)->withToClass(Dummy::class)->withIdentifiers(['id']), (new Link())->withFromProperty('bar')->withFromClass(AttributeResource::class)->withToClass(RelatedDummy::class)->withIdentifiers(['id']), ]), diff --git a/tests/Metadata/Resource/Factory/UriTemplateResourceMetadataCollectionFactoryTest.php b/tests/Metadata/Resource/Factory/UriTemplateResourceMetadataCollectionFactoryTest.php index 22948b75d22..8b27b5d5b7a 100644 --- a/tests/Metadata/Resource/Factory/UriTemplateResourceMetadataCollectionFactoryTest.php +++ b/tests/Metadata/Resource/Factory/UriTemplateResourceMetadataCollectionFactoryTest.php @@ -107,6 +107,20 @@ class: AttributeResource::class, uriTemplate: '/dummy/{dummyId}/attribute_resources/{id}', uriVariables: ['dummyId' => ['from_class' => Dummy::class, 'identifiers' => ['id']], 'id' => ['from_class' => AttributeResource::class, 'identifiers' => ['id']]], ), + new ApiResource( + shortName: 'AttributeResource', + class: AttributeResource::class, + uriVariables: ['id' => new Link(fromClass: AttributeResource::class, identifiers: ['id'])], + operations: [ + new Get( + shortName: 'AttributeResource', + class: AttributeResource::class, + controller: 'api_platform.action.placeholder', + uriVariables: ['id' => new Link(fromClass: AttributeResource::class, identifiers: ['id'])], + routePrefix: '/prefix', + ), + ] + ), ]), ); @@ -169,6 +183,21 @@ class: AttributeResource::class, uriVariables: ['dummyId' => new Link(fromClass: Dummy::class, identifiers: ['id'], parameterName: 'dummyId'), 'id' => new Link(fromClass: AttributeResource::class, identifiers: ['id'], parameterName: 'id')], operations: [], ), + new ApiResource( + uriVariables: ['id' => new Link(fromClass: AttributeResource::class, identifiers: ['id'], parameterName: 'id')], + shortName: 'AttributeResource', + class: AttributeResource::class, + operations: [ + '_api_/prefix/attribute_resources/{id}{._format}_get' => new Get( + uriTemplate: '/attribute_resources/{id}{._format}', + shortName: 'AttributeResource', + class: AttributeResource::class, + controller: 'api_platform.action.placeholder', + uriVariables: ['id' => new Link(fromClass: AttributeResource::class, identifiers: ['id'], parameterName: 'id')], + routePrefix: '/prefix', + name: '_api_/prefix/attribute_resources/{id}{._format}_get'), + ] + ), ]), $uriTemplateResourceMetadataCollectionFactory->create(AttributeResource::class) ); diff --git a/tests/Serializer/AbstractItemNormalizerTest.php b/tests/Serializer/AbstractItemNormalizerTest.php index a777bb15862..c9b89a26171 100644 --- a/tests/Serializer/AbstractItemNormalizerTest.php +++ b/tests/Serializer/AbstractItemNormalizerTest.php @@ -23,6 +23,9 @@ use ApiPlatform\Serializer\AbstractItemNormalizer; use ApiPlatform\Symfony\Security\ResourceAccessCheckerInterface; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyTableInheritance; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyTableInheritanceChild; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyTableInheritanceRelated; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\SecuredDummy; use Doctrine\Common\Collections\ArrayCollection; @@ -576,6 +579,70 @@ public function testNormalizeReadableLinks(): void ])); } + public function testNormalizePolymorphicRelations(): void + { + $concreteDummy = new DummyTableInheritanceChild(); + + $dummy = new DummyTableInheritanceRelated(); + $dummy->addChild($concreteDummy); + + $abstractDummies = new ArrayCollection([$concreteDummy]); + + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(DummyTableInheritanceRelated::class, [])->willReturn(new PropertyNameCollection(['children'])); + + $abstractDummyType = new Type(Type::BUILTIN_TYPE_OBJECT, false, DummyTableInheritance::class); + $abstractDummiesType = new Type(Type::BUILTIN_TYPE_OBJECT, false, ArrayCollection::class, true, new Type(Type::BUILTIN_TYPE_INT), $abstractDummyType); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(DummyTableInheritanceRelated::class, 'children', [])->willReturn((new ApiProperty())->withBuiltinTypes([$abstractDummiesType])->withReadable(true)->withWritable(false)->withReadableLink(true)); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getIriFromResource($dummy, Argument::cetera())->willReturn('/dummies/1'); + + $propertyAccessorProphecy = $this->prophesize(PropertyAccessorInterface::class); + $propertyAccessorProphecy->getValue($dummy, 'children')->willReturn($abstractDummies); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass($dummy, null)->willReturn(DummyTableInheritanceRelated::class); + $resourceClassResolverProphecy->getResourceClass(null, DummyTableInheritanceRelated::class)->willReturn(DummyTableInheritanceRelated::class); + $resourceClassResolverProphecy->getResourceClass($concreteDummy, DummyTableInheritance::class)->willReturn(DummyTableInheritanceChild::class); + $resourceClassResolverProphecy->getResourceClass($abstractDummies, DummyTableInheritance::class)->willReturn(DummyTableInheritance::class); + $resourceClassResolverProphecy->isResourceClass(DummyTableInheritanceRelated::class)->willReturn(true); + $resourceClassResolverProphecy->isResourceClass(DummyTableInheritance::class)->willReturn(true); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->willImplement(NormalizerInterface::class); + $concreteDummyChildContext = Argument::allOf( + Argument::type('array'), + Argument::withEntry('resource_class', DummyTableInheritanceChild::class), + Argument::not(Argument::withKey('iri')) + ); + $serializerProphecy->normalize($concreteDummy, null, $concreteDummyChildContext)->willReturn(['foo' => 'concrete']); + $serializerProphecy->normalize([['foo' => 'concrete']], null, Argument::type('array'))->willReturn([['foo' => 'concrete']]); + + $normalizer = $this->getMockForAbstractClass(AbstractItemNormalizer::class, [ + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal(), + $propertyAccessorProphecy->reveal(), + null, + null, + [], + null, + null, + ]); + $normalizer->setSerializer($serializerProphecy->reveal()); + + $expected = [ + 'children' => [['foo' => 'concrete']], + ]; + $this->assertSame($expected, $normalizer->normalize($dummy, null, [ + 'resources' => [], + ])); + } + public function testDenormalize(): void { $data = [