From 5d7876d2dbd5f174f316dfccfa3649fd4ad951a7 Mon Sep 17 00:00:00 2001 From: Andreas Schacht Date: Thu, 28 Nov 2019 01:20:36 +0100 Subject: [PATCH 1/2] JSON:API add inclusion of resources from path --- .../related-resouces-inclusion.feature | 663 ++++++++++++++++++ src/JsonApi/Serializer/ItemNormalizer.php | 94 ++- tests/Behat/DoctrineContext.php | 47 ++ 3 files changed, 801 insertions(+), 3 deletions(-) diff --git a/features/jsonapi/related-resouces-inclusion.feature b/features/jsonapi/related-resouces-inclusion.feature index d2850d7ea49..6d04d75db44 100644 --- a/features/jsonapi/related-resouces-inclusion.feature +++ b/features/jsonapi/related-resouces-inclusion.feature @@ -391,6 +391,452 @@ Feature: JSON API Inclusion of Related Resources } """ + @createSchema + Scenario: Request inclusion of resources from path + Given there are 1 dummy objects with relatedDummy and its thirdLevel + When I send a "GET" request to "/dummies/1?include=relatedDummy.thirdLevel" + Then the response status code should be 200 + And the response should be in JSON + And the JSON should be valid according to the JSON API schema + And the JSON should be deep equal to: + """ + { + "data": { + "id": "/dummies/1", + "type": "Dummy", + "attributes": { + "description": null, + "dummy": null, + "dummyBoolean": null, + "dummyDate": null, + "dummyFloat": null, + "dummyPrice": null, + "jsonData": [], + "arrayData": [], + "name_converted": null, + "_id": 1, + "name": "Dummy #1", + "alias": "Alias #0", + "foo": null + }, + "relationships": { + "relatedDummy": { + "data": { + "type": "RelatedDummy", + "id": "/related_dummies/1" + } + } + } + }, + "included": [ + { + "id": "/related_dummies/1", + "type": "RelatedDummy", + "attributes": { + "name": "RelatedDummy #1", + "dummyDate": null, + "dummyBoolean": null, + "embeddedDummy": { + "dummyName": null, + "dummyBoolean": null, + "dummyDate": null, + "dummyFloat": null, + "dummyPrice": null, + "symfony": null + }, + "_id": 1, + "symfony": "symfony", + "age": null + }, + "relationships": { + "thirdLevel": { + "data": { + "type": "ThirdLevel", + "id": "/third_levels/1" + } + } + } + }, + { + "id": "/third_levels/1", + "type": "ThirdLevel", + "attributes": { + "_id": 1, + "level": 3, + "test": true + } + } + ] + } + """ + + @createSchema + Scenario: Request inclusion of resources from path with collection + Given there is a dummy object with 3 relatedDummies and their thirdLevel + When I send a "GET" request to "/dummies/1?include=relatedDummies.thirdLevel" + Then the response status code should be 200 + And the response should be in JSON + And the JSON should be valid according to the JSON API schema + And the JSON should be deep equal to: + """ + { + "data": { + "id": "/dummies/1", + "type": "Dummy", + "attributes": { + "description": null, + "dummy": null, + "dummyBoolean": null, + "dummyDate": null, + "dummyFloat": null, + "dummyPrice": null, + "jsonData": [], + "arrayData": [], + "name_converted": null, + "_id": 1, + "name": "Dummy with relations", + "alias": null, + "foo": null + }, + "relationships": { + "relatedDummies": { + "data": [ + { + "type": "RelatedDummy", + "id": "/related_dummies/1" + }, + { + "type": "RelatedDummy", + "id": "/related_dummies/2" + }, + { + "type": "RelatedDummy", + "id": "/related_dummies/3" + } + ] + } + } + }, + "included": [ + { + "id": "/third_levels/1", + "type": "ThirdLevel", + "attributes": { + "_id": 1, + "level": 3, + "test": true + } + }, + { + "id": "/third_levels/2", + "type": "ThirdLevel", + "attributes": { + "_id": 2, + "level": 3, + "test": true + } + }, + { + "id": "/third_levels/3", + "type": "ThirdLevel", + "attributes": { + "_id": 3, + "level": 3, + "test": true + } + }, + { + "id": "/related_dummies/1", + "type": "RelatedDummy", + "attributes": { + "name": "RelatedDummy #1", + "dummyDate": null, + "dummyBoolean": null, + "embeddedDummy": { + "dummyName": null, + "dummyBoolean": null, + "dummyDate": null, + "dummyFloat": null, + "dummyPrice": null, + "symfony": null + }, + "_id": 1, + "symfony": "symfony", + "age": null + }, + "relationships": { + "thirdLevel": { + "data": { + "type": "ThirdLevel", + "id": "/third_levels/1" + } + } + } + }, + { + "id": "/related_dummies/2", + "type": "RelatedDummy", + "attributes": { + "name": "RelatedDummy #2", + "dummyDate": null, + "dummyBoolean": null, + "embeddedDummy": { + "dummyName": null, + "dummyBoolean": null, + "dummyDate": null, + "dummyFloat": null, + "dummyPrice": null, + "symfony": null + }, + "_id": 2, + "symfony": "symfony", + "age": null + }, + "relationships": { + "thirdLevel": { + "data": { + "type": "ThirdLevel", + "id": "/third_levels/2" + } + } + } + }, + { + "id": "/related_dummies/3", + "type": "RelatedDummy", + "attributes": { + "name": "RelatedDummy #3", + "dummyDate": null, + "dummyBoolean": null, + "embeddedDummy": { + "dummyName": null, + "dummyBoolean": null, + "dummyDate": null, + "dummyFloat": null, + "dummyPrice": null, + "symfony": null + }, + "_id": 3, + "symfony": "symfony", + "age": null + }, + "relationships": { + "thirdLevel": { + "data": { + "type": "ThirdLevel", + "id": "/third_levels/3" + } + } + } + } + ] + } + """ + + @createSchema + Scenario: Do not include the requested resource + Given there is a RelatedOwningDummy object with OneToOne relation + When I send a "GET" request to "/dummies/1?include=relatedOwningDummy.ownedDummy" + Then the response status code should be 200 + And the response should be in JSON + And the JSON should be valid according to the JSON API schema + And the JSON should be deep equal to: + """ + { + "data": { + "id": "/dummies/1", + "type": "Dummy", + "attributes": { + "description": null, + "dummy": null, + "dummyBoolean": null, + "dummyDate": null, + "dummyFloat": null, + "dummyPrice": null, + "jsonData": [], + "arrayData": [], + "name_converted": null, + "_id": 1, + "name": "plop", + "alias": null, + "foo": null + }, + "relationships": { + "relatedOwningDummy": { + "data": { + "type": "RelatedOwningDummy", + "id": "/related_owning_dummies/1" + } + } + } + }, + "included": [ + { + "id": "/related_owning_dummies/1", + "type": "RelatedOwningDummy", + "attributes": { + "name": null, + "_id": 1 + }, + "relationships": { + "ownedDummy": { + "data": { + "type": "Dummy", + "id": "/dummies/1" + } + } + } + } + ] + } + """ + + @createSchema + Scenario: Do not include resources multiple times + Given there is a dummy object with 3 relatedDummies with same thirdLevel + When I send a "GET" request to "/dummies/1?include=relatedDummies.thirdLevel" + Then the response status code should be 200 + And the response should be in JSON + And the JSON should be valid according to the JSON API schema + And the JSON should be deep equal to: + """ + { + "data": { + "id": "/dummies/1", + "type": "Dummy", + "attributes": { + "description": null, + "dummy": null, + "dummyBoolean": null, + "dummyDate": null, + "dummyFloat": null, + "dummyPrice": null, + "jsonData": [], + "arrayData": [], + "name_converted": null, + "_id": 1, + "name": "Dummy with relations", + "alias": null, + "foo": null + }, + "relationships": { + "relatedDummies": { + "data": [ + { + "type": "RelatedDummy", + "id": "/related_dummies/1" + }, + { + "type": "RelatedDummy", + "id": "/related_dummies/2" + }, + { + "type": "RelatedDummy", + "id": "/related_dummies/3" + } + ] + } + } + }, + "included": [ + { + "id": "/third_levels/1", + "type": "ThirdLevel", + "attributes": { + "_id": 1, + "level": 3, + "test": true + } + }, + { + "id": "/related_dummies/1", + "type": "RelatedDummy", + "attributes": { + "name": "RelatedDummy #1", + "dummyDate": null, + "dummyBoolean": null, + "embeddedDummy": { + "dummyName": null, + "dummyBoolean": null, + "dummyDate": null, + "dummyFloat": null, + "dummyPrice": null, + "symfony": null + }, + "_id": 1, + "symfony": "symfony", + "age": null + }, + "relationships": { + "thirdLevel": { + "data": { + "type": "ThirdLevel", + "id": "/third_levels/1" + } + } + } + }, + { + "id": "/related_dummies/2", + "type": "RelatedDummy", + "attributes": { + "name": "RelatedDummy #2", + "dummyDate": null, + "dummyBoolean": null, + "embeddedDummy": { + "dummyName": null, + "dummyBoolean": null, + "dummyDate": null, + "dummyFloat": null, + "dummyPrice": null, + "symfony": null + }, + "_id": 2, + "symfony": "symfony", + "age": null + }, + "relationships": { + "thirdLevel": { + "data": { + "type": "ThirdLevel", + "id": "/third_levels/1" + } + } + } + }, + { + "id": "/related_dummies/3", + "type": "RelatedDummy", + "attributes": { + "name": "RelatedDummy #3", + "dummyDate": null, + "dummyBoolean": null, + "embeddedDummy": { + "dummyName": null, + "dummyBoolean": null, + "dummyDate": null, + "dummyFloat": null, + "dummyPrice": null, + "symfony": null + }, + "_id": 3, + "symfony": "symfony", + "age": null + }, + "relationships": { + "thirdLevel": { + "data": { + "type": "ThirdLevel", + "id": "/third_levels/1" + } + } + } + } + ] + } + """ + + @createSchema Scenario: Request inclusion of a related resources on collection Given there are 3 dummy property objects @@ -674,3 +1120,220 @@ Feature: JSON API Inclusion of Related Resources }] } """ + + @createSchema + Scenario: Request inclusion from path of resource with relation + Given there are 3 dummy objects with relatedDummy and its thirdLevel + When I send a "GET" request to "/dummies?include=relatedDummy.thirdLevel" + Then the response status code should be 200 + And the response should be in JSON + And the JSON should be valid according to the JSON API schema + And the JSON should be deep equal to: + """ + { + "links": { + "self": "/dummies?include=relatedDummy.thirdLevel" + }, + "meta": { + "totalItems": 3, + "itemsPerPage": 3, + "currentPage": 1 + }, + "data": [ + { + "id": "/dummies/1", + "type": "Dummy", + "attributes": { + "description": null, + "dummy": null, + "dummyBoolean": null, + "dummyDate": null, + "dummyFloat": null, + "dummyPrice": null, + "jsonData": [], + "arrayData": [], + "name_converted": null, + "_id": 1, + "name": "Dummy #1", + "alias": "Alias #2", + "foo": null + }, + "relationships": { + "relatedDummy": { + "data": { + "type": "RelatedDummy", + "id": "/related_dummies/1" + } + } + } + }, + { + "id": "/dummies/2", + "type": "Dummy", + "attributes": { + "description": null, + "dummy": null, + "dummyBoolean": null, + "dummyDate": null, + "dummyFloat": null, + "dummyPrice": null, + "jsonData": [], + "arrayData": [], + "name_converted": null, + "_id": 2, + "name": "Dummy #2", + "alias": "Alias #1", + "foo": null + }, + "relationships": { + "relatedDummy": { + "data": { + "type": "RelatedDummy", + "id": "/related_dummies/2" + } + } + } + }, + { + "id": "/dummies/3", + "type": "Dummy", + "attributes": { + "description": null, + "dummy": null, + "dummyBoolean": null, + "dummyDate": null, + "dummyFloat": null, + "dummyPrice": null, + "jsonData": [], + "arrayData": [], + "name_converted": null, + "_id": 3, + "name": "Dummy #3", + "alias": "Alias #0", + "foo": null + }, + "relationships": { + "relatedDummy": { + "data": { + "type": "RelatedDummy", + "id": "/related_dummies/3" + } + } + } + } + ], + "included": [ + { + "id": "/third_levels/1", + "type": "ThirdLevel", + "attributes": { + "_id": 1, + "level": 3, + "test": true + } + }, + { + "id": "/third_levels/2", + "type": "ThirdLevel", + "attributes": { + "_id": 2, + "level": 3, + "test": true + } + }, + { + "id": "/third_levels/3", + "type": "ThirdLevel", + "attributes": { + "_id": 3, + "level": 3, + "test": true + } + }, + { + "id": "/related_dummies/1", + "type": "RelatedDummy", + "attributes": { + "name": "RelatedDummy #1", + "dummyDate": null, + "dummyBoolean": null, + "embeddedDummy": { + "dummyName": null, + "dummyBoolean": null, + "dummyDate": null, + "dummyFloat": null, + "dummyPrice": null, + "symfony": null + }, + "_id": 1, + "symfony": "symfony", + "age": null + }, + "relationships": { + "thirdLevel": { + "data": { + "type": "ThirdLevel", + "id": "/third_levels/1" + } + } + } + }, + { + "id": "/related_dummies/2", + "type": "RelatedDummy", + "attributes": { + "name": "RelatedDummy #2", + "dummyDate": null, + "dummyBoolean": null, + "embeddedDummy": { + "dummyName": null, + "dummyBoolean": null, + "dummyDate": null, + "dummyFloat": null, + "dummyPrice": null, + "symfony": null + }, + "_id": 2, + "symfony": "symfony", + "age": null + }, + "relationships": { + "thirdLevel": { + "data": { + "type": "ThirdLevel", + "id": "/third_levels/2" + } + } + } + }, + { + "id": "/related_dummies/3", + "type": "RelatedDummy", + "attributes": { + "name": "RelatedDummy #3", + "dummyDate": null, + "dummyBoolean": null, + "embeddedDummy": { + "dummyName": null, + "dummyBoolean": null, + "dummyDate": null, + "dummyFloat": null, + "dummyPrice": null, + "symfony": null + }, + "_id": 3, + "symfony": "symfony", + "age": null + }, + "relationships": { + "thirdLevel": { + "data": { + "type": "ThirdLevel", + "id": "/third_levels/3" + } + } + } + } + ] + } + """ diff --git a/src/JsonApi/Serializer/ItemNormalizer.php b/src/JsonApi/Serializer/ItemNormalizer.php index 2beb8cab51a..b71b34ac74a 100644 --- a/src/JsonApi/Serializer/ItemNormalizer.php +++ b/src/JsonApi/Serializer/ItemNormalizer.php @@ -94,6 +94,19 @@ public function normalize($object, $format = null, array $context = []) $allRelationshipsData = $this->getComponents($object, $format, $context)['relationships']; $populatedRelationContext = $context; $relationshipsData = $this->getPopulatedRelations($object, $format, $populatedRelationContext, $allRelationshipsData); + + if (!isset($context['api_included_resources'])) { + // initialize tracking of already included resources + $context['api_included_resources'] = []; + } + + // do not include primary resources + $context['api_included_resources'] = [ + $resourceMetadata->getShortName() => [ + $context['iri'], + ], + ]; + $includedResourcesData = $this->getRelatedResources($object, $format, $context, $allRelationshipsData); $resourceData = [ @@ -370,12 +383,19 @@ private function getRelatedResources($object, ?string $format, array $context, a $included = []; foreach ($relationships as $relationshipDataArray) { - if (!\in_array($relationshipDataArray['name'], $context['api_included'], true)) { + $relationshipName = $relationshipDataArray['name']; + + if (!$this->shouldIncludeRelation($relationshipName, $context)) { continue; } $relationshipName = $relationshipDataArray['name']; $relationContext = $context; + if ($this->isIntermediateResource($relationshipName, $context)) { + $relationContext['api_included'] = $this->getNestedResources($relationshipName, $context); + } else { + $relationContext['api_included'] = []; + } $attributeValue = $this->getAttributeValue($object, $relationshipName, $format, $relationContext); if (!$attributeValue) { @@ -384,18 +404,86 @@ private function getRelatedResources($object, ?string $format, array $context, a // Many to one relationship if ('one' === $relationshipDataArray['cardinality']) { - $included[] = $attributeValue['data']; + $this->addIncluded($attributeValue['data'], $included, $context); + if (isset($attributeValue['included']) && \is_array($attributeValue['included'])) { + foreach ($attributeValue['included'] as $include) { + $this->addIncluded($include, $included, $context); + } + } continue; } // Many to many relationship foreach ($attributeValue as $attributeValueElement) { if (isset($attributeValueElement['data'])) { - $included[] = $attributeValueElement['data']; + $this->addIncluded($attributeValueElement['data'], $included, $context); + + if (isset($attributeValueElement['included']) && \is_array($attributeValueElement['included'])) { + foreach ($attributeValueElement['included'] as $include) { + $this->addIncluded($include, $included, $context); + } + } } } } return $included; } + + /** + * add data to included array if it's not already. + * + * @param array $data + * @param array $included + * @param array $context + */ + private function addIncluded($data, &$included, &$context) + { + if (isset($data['id']) && $data['type']) { + if (!isset($context['api_included_resources'][$data['type']])) { + $context['api_included_resources'][$data['type']] = []; + } + if (!\in_array($data['id'], $context['api_included_resources'][$data['type']], true)) { + $included[] = $data; + // track already included resources + $context['api_included_resources'][$data['type']][] = $data['id']; + } + } + } + + /** + * figure out if the relationship is in the api_included hash, at least as an intermediate resource. + */ + private function shouldIncludeRelation(string $relationshipName, array $context): bool + { + $normalizedName = $this->nameConverter ? $this->nameConverter->normalize($relationshipName, $context['resource_class'], self::FORMAT, $context) : $relationshipName; + + return \in_array($normalizedName, $context['api_included'], true) || $this->isIntermediateResource($relationshipName, $context); + } + + /** + * returns true if the relationship is an intermediate resource. + */ + private function isIntermediateResource(string $relationshipName, array $context): bool + { + $nested = $this->getNestedResources($relationshipName, $context); + + return \count($nested) > 0; + } + + /** + * returns the names of the nested resources from an relationship. + */ + private function getNestedResources(string $relationshipName, array $context): array + { + $normalizedName = $this->nameConverter ? $this->nameConverter->normalize($relationshipName, $context['resource_class'], self::FORMAT, $context) : $relationshipName; + + $filtered = array_filter($context['api_included'], function (string $included) use ($normalizedName) { + return 0 === strpos($included, $normalizedName.'.'); + }); + + return array_map(function (string $nested) { + return substr($nested, strpos($nested, '.') + 1); + }, $filtered); + } } diff --git a/tests/Behat/DoctrineContext.php b/tests/Behat/DoctrineContext.php index b2f7ae5066d..d10431be6a2 100644 --- a/tests/Behat/DoctrineContext.php +++ b/tests/Behat/DoctrineContext.php @@ -617,6 +617,53 @@ public function thereAreDummyObjectsWithRelatedDummyAndItsThirdLevel(int $nb) $this->manager->flush(); } + /** + * @Given there is a dummy object with :nb relatedDummies and their thirdLevel + */ + public function thereIsADummyObjectWithRelatedDummiesAndTheirThirdLevel(int $nb) + { + $dummy = $this->buildDummy(); + $dummy->setName('Dummy with relations'); + + for ($i = 1; $i <= $nb; ++$i) { + $thirdLevel = $this->buildThirdLevel(); + + $relatedDummy = $this->buildRelatedDummy(); + $relatedDummy->setName('RelatedDummy #'.$i); + $relatedDummy->setThirdLevel($thirdLevel); + + $dummy->addRelatedDummy($relatedDummy); + + $this->manager->persist($thirdLevel); + $this->manager->persist($relatedDummy); + } + $this->manager->persist($dummy); + $this->manager->flush(); + } + + /** + * @Given there is a dummy object with :nb relatedDummies with same thirdLevel + */ + public function thereIsADummyObjectWithRelatedDummiesWithSameThirdLevel(int $nb) + { + $dummy = $this->buildDummy(); + $dummy->setName('Dummy with relations'); + $thirdLevel = $this->buildThirdLevel(); + + for ($i = 1; $i <= $nb; ++$i) { + $relatedDummy = $this->buildRelatedDummy(); + $relatedDummy->setName('RelatedDummy #'.$i); + $relatedDummy->setThirdLevel($thirdLevel); + + $dummy->addRelatedDummy($relatedDummy); + + $this->manager->persist($relatedDummy); + } + $this->manager->persist($thirdLevel); + $this->manager->persist($dummy); + $this->manager->flush(); + } + /** * @Given there are :nb dummy objects with embeddedDummy */ From bb3d6134abd0e7f06d10669929a306687c46b2ce Mon Sep 17 00:00:00 2001 From: Alan Poulain Date: Wed, 17 Mar 2021 16:49:29 +0100 Subject: [PATCH 2/2] fixes --- CHANGELOG.md | 1 + .../related-resouces-inclusion.feature | 852 +++++++++--------- src/JsonApi/Serializer/ItemNormalizer.php | 80 +- 3 files changed, 462 insertions(+), 471 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e304f6de76d..8fff37221a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ * Doctrine: Add `nulls_always_first` and `nulls_always_last` to `nulls_comparison` in order filter (#4103) * MongoDB: `date_immutable` support (#3940) * DataProvider: Add `TraversablePaginator` (#3783) +* JSON:API: Support inclusion of resources from path (#3288) * Swagger UI: Add `swagger_ui_extra_configuration` to Swagger / OpenAPI configuration (#3731) ## 2.6.3 diff --git a/features/jsonapi/related-resouces-inclusion.feature b/features/jsonapi/related-resouces-inclusion.feature index 6d04d75db44..a7d07dbf3b5 100644 --- a/features/jsonapi/related-resouces-inclusion.feature +++ b/features/jsonapi/related-resouces-inclusion.feature @@ -393,81 +393,109 @@ Feature: JSON API Inclusion of Related Resources @createSchema Scenario: Request inclusion of resources from path - Given there are 1 dummy objects with relatedDummy and its thirdLevel - When I send a "GET" request to "/dummies/1?include=relatedDummy.thirdLevel" + Given there is a dummy object with a fourth level relation + When I send a "GET" request to "/dummies/1?include=relatedDummy.thirdLevel.fourthLevel" Then the response status code should be 200 And the response should be in JSON And the JSON should be valid according to the JSON API schema - And the JSON should be deep equal to: + And the JSON should be equal to: """ - { - "data": { - "id": "/dummies/1", - "type": "Dummy", - "attributes": { - "description": null, - "dummy": null, - "dummyBoolean": null, - "dummyDate": null, - "dummyFloat": null, - "dummyPrice": null, - "jsonData": [], - "arrayData": [], - "name_converted": null, - "_id": 1, - "name": "Dummy #1", - "alias": "Alias #0", - "foo": null + { + "data": { + "id": "/dummies/1", + "type": "Dummy", + "attributes": { + "_id": 1, + "name": "Dummy with relations", + "alias": null, + "foo": null, + "description": null, + "dummy": null, + "dummyBoolean": null, + "dummyDate": null, + "dummyFloat": null, + "dummyPrice": null, + "jsonData": [], + "arrayData": [], + "name_converted": null + }, + "relationships": { + "relatedDummy": { + "data": { + "type": "RelatedDummy", + "id": "/related_dummies/1" + } }, - "relationships": { - "relatedDummy": { - "data": { + "relatedDummies": { + "data": [ + { "type": "RelatedDummy", "id": "/related_dummies/1" + }, + { + "type": "RelatedDummy", + "id": "/related_dummies/2" } - } + ] } - }, - "included": [ - { - "id": "/related_dummies/1", - "type": "RelatedDummy", - "attributes": { - "name": "RelatedDummy #1", - "dummyDate": null, + } + }, + "included": [ + { + "id": "/related_dummies/1", + "type": "RelatedDummy", + "attributes": { + "_id": 1, + "name": "Hello", + "symfony": "symfony", + "dummyDate": null, + "dummyBoolean": null, + "embeddedDummy": { + "dummyName": null, "dummyBoolean": null, - "embeddedDummy": { - "dummyName": null, - "dummyBoolean": null, - "dummyDate": null, - "dummyFloat": null, - "dummyPrice": null, - "symfony": null - }, - "_id": 1, - "symfony": "symfony", - "age": null + "dummyDate": null, + "dummyFloat": null, + "dummyPrice": null, + "symfony": null }, - "relationships": { - "thirdLevel": { - "data": { - "type": "ThirdLevel", - "id": "/third_levels/1" - } + "age": null + }, + "relationships": { + "thirdLevel": { + "data": { + "type": "ThirdLevel", + "id": "/third_levels/1" } } + } + }, + { + "id": "/third_levels/1", + "type": "ThirdLevel", + "attributes": { + "_id": 1, + "level": 3, + "test": true }, - { - "id": "/third_levels/1", - "type": "ThirdLevel", - "attributes": { - "_id": 1, - "level": 3, - "test": true + "relationships": { + "fourthLevel": { + "data": { + "type": "FourthLevel", + "id": "/fourth_levels/1" + } } } - ] - } + }, + { + "id": "/fourth_levels/1", + "type": "FourthLevel", + "attributes": { + "_id": 1, + "level": 4 + } + } + ] + } """ @createSchema @@ -477,160 +505,160 @@ Feature: JSON API Inclusion of Related Resources Then the response status code should be 200 And the response should be in JSON And the JSON should be valid according to the JSON API schema - And the JSON should be deep equal to: + And the JSON should be equal to: """ - { - "data": { - "id": "/dummies/1", - "type": "Dummy", + { + "data": { + "id": "/dummies/1", + "type": "Dummy", + "attributes": { + "_id": 1, + "name": "Dummy with relations", + "alias": null, + "foo": null, + "description": null, + "dummy": null, + "dummyBoolean": null, + "dummyDate": null, + "dummyFloat": null, + "dummyPrice": null, + "jsonData": [], + "arrayData": [], + "name_converted": null + }, + "relationships": { + "relatedDummies": { + "data": [ + { + "type": "RelatedDummy", + "id": "/related_dummies/1" + }, + { + "type": "RelatedDummy", + "id": "/related_dummies/2" + }, + { + "type": "RelatedDummy", + "id": "/related_dummies/3" + } + ] + } + } + }, + "included": [ + { + "id": "/related_dummies/1", + "type": "RelatedDummy", "attributes": { - "description": null, - "dummy": null, - "dummyBoolean": null, - "dummyDate": null, - "dummyFloat": null, - "dummyPrice": null, - "jsonData": [], - "arrayData": [], - "name_converted": null, "_id": 1, - "name": "Dummy with relations", - "alias": null, - "foo": null + "name": "RelatedDummy #1", + "symfony": "symfony", + "dummyDate": null, + "dummyBoolean": null, + "embeddedDummy": { + "dummyName": null, + "dummyBoolean": null, + "dummyDate": null, + "dummyFloat": null, + "dummyPrice": null, + "symfony": null + }, + "age": null }, "relationships": { - "relatedDummies": { - "data": [ - { - "type": "RelatedDummy", - "id": "/related_dummies/1" - }, - { - "type": "RelatedDummy", - "id": "/related_dummies/2" - }, - { - "type": "RelatedDummy", - "id": "/related_dummies/3" - } - ] - } + "thirdLevel": { + "data": { + "type": "ThirdLevel", + "id": "/third_levels/1" + } + } } }, - "included": [ - { - "id": "/third_levels/1", - "type": "ThirdLevel", - "attributes": { - "_id": 1, - "level": 3, - "test": true - } - }, - { - "id": "/third_levels/2", - "type": "ThirdLevel", - "attributes": { - "_id": 2, - "level": 3, - "test": true - } - }, - { - "id": "/third_levels/3", - "type": "ThirdLevel", - "attributes": { - "_id": 3, - "level": 3, - "test": true - } - }, - { - "id": "/related_dummies/1", - "type": "RelatedDummy", - "attributes": { - "name": "RelatedDummy #1", - "dummyDate": null, + { + "id": "/third_levels/1", + "type": "ThirdLevel", + "attributes": { + "_id": 1, + "level": 3, + "test": true + } + }, + { + "id": "/related_dummies/2", + "type": "RelatedDummy", + "attributes": { + "_id": 2, + "name": "RelatedDummy #2", + "symfony": "symfony", + "dummyDate": null, + "dummyBoolean": null, + "embeddedDummy": { + "dummyName": null, "dummyBoolean": null, - "embeddedDummy": { - "dummyName": null, - "dummyBoolean": null, - "dummyDate": null, - "dummyFloat": null, - "dummyPrice": null, - "symfony": null - }, - "_id": 1, - "symfony": "symfony", - "age": null - }, - "relationships": { - "thirdLevel": { - "data": { - "type": "ThirdLevel", - "id": "/third_levels/1" - } - } - } - }, - { - "id": "/related_dummies/2", - "type": "RelatedDummy", - "attributes": { - "name": "RelatedDummy #2", "dummyDate": null, - "dummyBoolean": null, - "embeddedDummy": { - "dummyName": null, - "dummyBoolean": null, - "dummyDate": null, - "dummyFloat": null, - "dummyPrice": null, - "symfony": null - }, - "_id": 2, - "symfony": "symfony", - "age": null + "dummyFloat": null, + "dummyPrice": null, + "symfony": null }, - "relationships": { - "thirdLevel": { - "data": { - "type": "ThirdLevel", - "id": "/third_levels/2" - } + "age": null + }, + "relationships": { + "thirdLevel": { + "data": { + "type": "ThirdLevel", + "id": "/third_levels/2" } } - }, - { - "id": "/related_dummies/3", - "type": "RelatedDummy", - "attributes": { - "name": "RelatedDummy #3", - "dummyDate": null, + } + }, + { + "id": "/third_levels/2", + "type": "ThirdLevel", + "attributes": { + "_id": 2, + "level": 3, + "test": true + } + }, + { + "id": "/related_dummies/3", + "type": "RelatedDummy", + "attributes": { + "_id": 3, + "name": "RelatedDummy #3", + "symfony": "symfony", + "dummyDate": null, + "dummyBoolean": null, + "embeddedDummy": { + "dummyName": null, "dummyBoolean": null, - "embeddedDummy": { - "dummyName": null, - "dummyBoolean": null, - "dummyDate": null, - "dummyFloat": null, - "dummyPrice": null, - "symfony": null - }, - "_id": 3, - "symfony": "symfony", - "age": null + "dummyDate": null, + "dummyFloat": null, + "dummyPrice": null, + "symfony": null }, - "relationships": { - "thirdLevel": { - "data": { - "type": "ThirdLevel", - "id": "/third_levels/3" - } + "age": null + }, + "relationships": { + "thirdLevel": { + "data": { + "type": "ThirdLevel", + "id": "/third_levels/3" } } } - ] - } + }, + { + "id": "/third_levels/3", + "type": "ThirdLevel", + "attributes": { + "_id": 3, + "level": 3, + "test": true + } + } + ] + } """ @createSchema @@ -640,200 +668,200 @@ Feature: JSON API Inclusion of Related Resources Then the response status code should be 200 And the response should be in JSON And the JSON should be valid according to the JSON API schema - And the JSON should be deep equal to: + And the JSON should be equal to: """ - { - "data": { - "id": "/dummies/1", - "type": "Dummy", + { + "data": { + "id": "/dummies/1", + "type": "Dummy", + "attributes": { + "description": null, + "dummy": null, + "dummyBoolean": null, + "dummyDate": null, + "dummyFloat": null, + "dummyPrice": null, + "jsonData": [], + "arrayData": [], + "name_converted": null, + "_id": 1, + "name": "plop", + "alias": null, + "foo": null + }, + "relationships": { + "relatedOwningDummy": { + "data": { + "type": "RelatedOwningDummy", + "id": "/related_owning_dummies/1" + } + } + } + }, + "included": [ + { + "id": "/related_owning_dummies/1", + "type": "RelatedOwningDummy", "attributes": { - "description": null, - "dummy": null, - "dummyBoolean": null, - "dummyDate": null, - "dummyFloat": null, - "dummyPrice": null, - "jsonData": [], - "arrayData": [], - "name_converted": null, - "_id": 1, - "name": "plop", - "alias": null, - "foo": null + "name": null, + "_id": 1 }, "relationships": { - "relatedOwningDummy": { + "ownedDummy": { "data": { - "type": "RelatedOwningDummy", - "id": "/related_owning_dummies/1" + "type": "Dummy", + "id": "/dummies/1" } } } + } + ] + } + """ + + @createSchema + Scenario: Do not include resources multiple times + Given there is a dummy object with 3 relatedDummies with same thirdLevel + When I send a "GET" request to "/dummies/1?include=relatedDummies.thirdLevel" + Then the response status code should be 200 + And the response should be in JSON + And the JSON should be valid according to the JSON API schema + And the JSON should be equal to: + """ + { + "data": { + "id": "/dummies/1", + "type": "Dummy", + "attributes": { + "_id": 1, + "name": "Dummy with relations", + "alias": null, + "foo": null, + "description": null, + "dummy": null, + "dummyBoolean": null, + "dummyDate": null, + "dummyFloat": null, + "dummyPrice": null, + "jsonData": [], + "arrayData": [], + "name_converted": null }, - "included": [ - { - "id": "/related_owning_dummies/1", - "type": "RelatedOwningDummy", - "attributes": { - "name": null, - "_id": 1 + "relationships": { + "relatedDummies": { + "data": [ + { + "type": "RelatedDummy", + "id": "/related_dummies/1" + }, + { + "type": "RelatedDummy", + "id": "/related_dummies/2" + }, + { + "type": "RelatedDummy", + "id": "/related_dummies/3" + } + ] + } + } + }, + "included": [ + { + "id": "/related_dummies/1", + "type": "RelatedDummy", + "attributes": { + "_id": 1, + "name": "RelatedDummy #1", + "symfony": "symfony", + "dummyDate": null, + "dummyBoolean": null, + "embeddedDummy": { + "dummyName": null, + "dummyBoolean": null, + "dummyDate": null, + "dummyFloat": null, + "dummyPrice": null, + "symfony": null }, - "relationships": { - "ownedDummy": { - "data": { - "type": "Dummy", - "id": "/dummies/1" - } + "age": null + }, + "relationships": { + "thirdLevel": { + "data": { + "type": "ThirdLevel", + "id": "/third_levels/1" } } } - ] - } - """ - - @createSchema - Scenario: Do not include resources multiple times - Given there is a dummy object with 3 relatedDummies with same thirdLevel - When I send a "GET" request to "/dummies/1?include=relatedDummies.thirdLevel" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON API schema - And the JSON should be deep equal to: - """ - { - "data": { - "id": "/dummies/1", - "type": "Dummy", + }, + { + "id": "/third_levels/1", + "type": "ThirdLevel", "attributes": { - "description": null, - "dummy": null, - "dummyBoolean": null, - "dummyDate": null, - "dummyFloat": null, - "dummyPrice": null, - "jsonData": [], - "arrayData": [], - "name_converted": null, "_id": 1, - "name": "Dummy with relations", - "alias": null, - "foo": null - }, - "relationships": { - "relatedDummies": { - "data": [ - { - "type": "RelatedDummy", - "id": "/related_dummies/1" - }, - { - "type": "RelatedDummy", - "id": "/related_dummies/2" - }, - { - "type": "RelatedDummy", - "id": "/related_dummies/3" - } - ] - } + "level": 3, + "test": true } }, - "included": [ - { - "id": "/third_levels/1", - "type": "ThirdLevel", - "attributes": { - "_id": 1, - "level": 3, - "test": true - } - }, - { - "id": "/related_dummies/1", - "type": "RelatedDummy", - "attributes": { - "name": "RelatedDummy #1", - "dummyDate": null, + { + "id": "/related_dummies/2", + "type": "RelatedDummy", + "attributes": { + "_id": 2, + "name": "RelatedDummy #2", + "symfony": "symfony", + "dummyDate": null, + "dummyBoolean": null, + "embeddedDummy": { + "dummyName": null, "dummyBoolean": null, - "embeddedDummy": { - "dummyName": null, - "dummyBoolean": null, - "dummyDate": null, - "dummyFloat": null, - "dummyPrice": null, - "symfony": null - }, - "_id": 1, - "symfony": "symfony", - "age": null - }, - "relationships": { - "thirdLevel": { - "data": { - "type": "ThirdLevel", - "id": "/third_levels/1" - } - } - } - }, - { - "id": "/related_dummies/2", - "type": "RelatedDummy", - "attributes": { - "name": "RelatedDummy #2", "dummyDate": null, - "dummyBoolean": null, - "embeddedDummy": { - "dummyName": null, - "dummyBoolean": null, - "dummyDate": null, - "dummyFloat": null, - "dummyPrice": null, - "symfony": null - }, - "_id": 2, - "symfony": "symfony", - "age": null + "dummyFloat": null, + "dummyPrice": null, + "symfony": null }, - "relationships": { - "thirdLevel": { - "data": { - "type": "ThirdLevel", - "id": "/third_levels/1" - } + "age": null + }, + "relationships": { + "thirdLevel": { + "data": { + "type": "ThirdLevel", + "id": "/third_levels/1" } } - }, - { - "id": "/related_dummies/3", - "type": "RelatedDummy", - "attributes": { - "name": "RelatedDummy #3", - "dummyDate": null, + } + }, + { + "id": "/related_dummies/3", + "type": "RelatedDummy", + "attributes": { + "_id": 3, + "name": "RelatedDummy #3", + "symfony": "symfony", + "dummyDate": null, + "dummyBoolean": null, + "embeddedDummy": { + "dummyName": null, "dummyBoolean": null, - "embeddedDummy": { - "dummyName": null, - "dummyBoolean": null, - "dummyDate": null, - "dummyFloat": null, - "dummyPrice": null, - "symfony": null - }, - "_id": 3, - "symfony": "symfony", - "age": null + "dummyDate": null, + "dummyFloat": null, + "dummyPrice": null, + "symfony": null }, - "relationships": { - "thirdLevel": { - "data": { - "type": "ThirdLevel", - "id": "/third_levels/1" - } + "age": null + }, + "relationships": { + "thirdLevel": { + "data": { + "type": "ThirdLevel", + "id": "/third_levels/1" } } } - ] - } + } + ] + } """ @@ -1128,7 +1156,7 @@ Feature: JSON API Inclusion of Related Resources Then the response status code should be 200 And the response should be in JSON And the JSON should be valid according to the JSON API schema - And the JSON should be deep equal to: + And the JSON should be equal to: """ { "links": { @@ -1144,6 +1172,10 @@ Feature: JSON API Inclusion of Related Resources "id": "/dummies/1", "type": "Dummy", "attributes": { + "_id": 1, + "name": "Dummy #1", + "alias": "Alias #2", + "foo": null, "description": null, "dummy": null, "dummyBoolean": null, @@ -1152,11 +1184,7 @@ Feature: JSON API Inclusion of Related Resources "dummyPrice": null, "jsonData": [], "arrayData": [], - "name_converted": null, - "_id": 1, - "name": "Dummy #1", - "alias": "Alias #2", - "foo": null + "name_converted": null }, "relationships": { "relatedDummy": { @@ -1171,6 +1199,10 @@ Feature: JSON API Inclusion of Related Resources "id": "/dummies/2", "type": "Dummy", "attributes": { + "_id": 2, + "name": "Dummy #2", + "alias": "Alias #1", + "foo": null, "description": null, "dummy": null, "dummyBoolean": null, @@ -1179,11 +1211,7 @@ Feature: JSON API Inclusion of Related Resources "dummyPrice": null, "jsonData": [], "arrayData": [], - "name_converted": null, - "_id": 2, - "name": "Dummy #2", - "alias": "Alias #1", - "foo": null + "name_converted": null }, "relationships": { "relatedDummy": { @@ -1198,6 +1226,10 @@ Feature: JSON API Inclusion of Related Resources "id": "/dummies/3", "type": "Dummy", "attributes": { + "_id": 3, + "name": "Dummy #3", + "alias": "Alias #0", + "foo": null, "description": null, "dummy": null, "dummyBoolean": null, @@ -1206,11 +1238,7 @@ Feature: JSON API Inclusion of Related Resources "dummyPrice": null, "jsonData": [], "arrayData": [], - "name_converted": null, - "_id": 3, - "name": "Dummy #3", - "alias": "Alias #0", - "foo": null + "name_converted": null }, "relationships": { "relatedDummy": { @@ -1223,38 +1251,13 @@ Feature: JSON API Inclusion of Related Resources } ], "included": [ - { - "id": "/third_levels/1", - "type": "ThirdLevel", - "attributes": { - "_id": 1, - "level": 3, - "test": true - } - }, - { - "id": "/third_levels/2", - "type": "ThirdLevel", - "attributes": { - "_id": 2, - "level": 3, - "test": true - } - }, - { - "id": "/third_levels/3", - "type": "ThirdLevel", - "attributes": { - "_id": 3, - "level": 3, - "test": true - } - }, { "id": "/related_dummies/1", "type": "RelatedDummy", "attributes": { + "_id": 1, "name": "RelatedDummy #1", + "symfony": "symfony", "dummyDate": null, "dummyBoolean": null, "embeddedDummy": { @@ -1265,8 +1268,6 @@ Feature: JSON API Inclusion of Related Resources "dummyPrice": null, "symfony": null }, - "_id": 1, - "symfony": "symfony", "age": null }, "relationships": { @@ -1278,11 +1279,22 @@ Feature: JSON API Inclusion of Related Resources } } }, + { + "id": "/third_levels/1", + "type": "ThirdLevel", + "attributes": { + "_id": 1, + "level": 3, + "test": true + } + }, { "id": "/related_dummies/2", "type": "RelatedDummy", "attributes": { + "_id": 2, "name": "RelatedDummy #2", + "symfony": "symfony", "dummyDate": null, "dummyBoolean": null, "embeddedDummy": { @@ -1293,8 +1305,6 @@ Feature: JSON API Inclusion of Related Resources "dummyPrice": null, "symfony": null }, - "_id": 2, - "symfony": "symfony", "age": null }, "relationships": { @@ -1306,11 +1316,22 @@ Feature: JSON API Inclusion of Related Resources } } }, + { + "id": "/third_levels/2", + "type": "ThirdLevel", + "attributes": { + "_id": 2, + "level": 3, + "test": true + } + }, { "id": "/related_dummies/3", "type": "RelatedDummy", "attributes": { + "_id": 3, "name": "RelatedDummy #3", + "symfony": "symfony", "dummyDate": null, "dummyBoolean": null, "embeddedDummy": { @@ -1321,8 +1342,6 @@ Feature: JSON API Inclusion of Related Resources "dummyPrice": null, "symfony": null }, - "_id": 3, - "symfony": "symfony", "age": null }, "relationships": { @@ -1333,6 +1352,15 @@ Feature: JSON API Inclusion of Related Resources } } } + }, + { + "id": "/third_levels/3", + "type": "ThirdLevel", + "attributes": { + "_id": 3, + "level": 3, + "test": true + } } ] } diff --git a/src/JsonApi/Serializer/ItemNormalizer.php b/src/JsonApi/Serializer/ItemNormalizer.php index b71b34ac74a..bf588d8c02f 100644 --- a/src/JsonApi/Serializer/ItemNormalizer.php +++ b/src/JsonApi/Serializer/ItemNormalizer.php @@ -95,17 +95,8 @@ public function normalize($object, $format = null, array $context = []) $populatedRelationContext = $context; $relationshipsData = $this->getPopulatedRelations($object, $format, $populatedRelationContext, $allRelationshipsData); - if (!isset($context['api_included_resources'])) { - // initialize tracking of already included resources - $context['api_included_resources'] = []; - } - - // do not include primary resources - $context['api_included_resources'] = [ - $resourceMetadata->getShortName() => [ - $context['iri'], - ], - ]; + // Do not include primary resources + $context['api_included_resources'] = [$context['iri']]; $includedResourcesData = $this->getRelatedResources($object, $format, $context, $allRelationshipsData); @@ -389,35 +380,25 @@ private function getRelatedResources($object, ?string $format, array $context, a continue; } - $relationshipName = $relationshipDataArray['name']; $relationContext = $context; - if ($this->isIntermediateResource($relationshipName, $context)) { - $relationContext['api_included'] = $this->getNestedResources($relationshipName, $context); - } else { - $relationContext['api_included'] = []; - } + $relationContext['api_included'] = $this->getIncludedNestedResources($relationshipName, $context); + $attributeValue = $this->getAttributeValue($object, $relationshipName, $format, $relationContext); if (!$attributeValue) { continue; } + // Many to many relationship + $attributeValues = $attributeValue; // Many to one relationship if ('one' === $relationshipDataArray['cardinality']) { - $this->addIncluded($attributeValue['data'], $included, $context); - if (isset($attributeValue['included']) && \is_array($attributeValue['included'])) { - foreach ($attributeValue['included'] as $include) { - $this->addIncluded($include, $included, $context); - } - } - - continue; + $attributeValues = [$attributeValue]; } - // Many to many relationship - foreach ($attributeValue as $attributeValueElement) { + + foreach ($attributeValues as $attributeValueElement) { if (isset($attributeValueElement['data'])) { $this->addIncluded($attributeValueElement['data'], $included, $context); - if (isset($attributeValueElement['included']) && \is_array($attributeValueElement['included'])) { foreach ($attributeValueElement['included'] as $include) { $this->addIncluded($include, $included, $context); @@ -431,58 +412,39 @@ private function getRelatedResources($object, ?string $format, array $context, a } /** - * add data to included array if it's not already. - * - * @param array $data - * @param array $included - * @param array $context + * Add data to included array if it's not already included. */ - private function addIncluded($data, &$included, &$context) + private function addIncluded(array $data, array &$included, array &$context): void { - if (isset($data['id']) && $data['type']) { - if (!isset($context['api_included_resources'][$data['type']])) { - $context['api_included_resources'][$data['type']] = []; - } - if (!\in_array($data['id'], $context['api_included_resources'][$data['type']], true)) { - $included[] = $data; - // track already included resources - $context['api_included_resources'][$data['type']][] = $data['id']; - } + if (isset($data['id']) && !\in_array($data['id'], $context['api_included_resources'], true)) { + $included[] = $data; + // Track already included resources + $context['api_included_resources'][] = $data['id']; } } /** - * figure out if the relationship is in the api_included hash, at least as an intermediate resource. + * Figures out if the relationship is in the api_included hash or has included nested resources (path). */ private function shouldIncludeRelation(string $relationshipName, array $context): bool { $normalizedName = $this->nameConverter ? $this->nameConverter->normalize($relationshipName, $context['resource_class'], self::FORMAT, $context) : $relationshipName; - return \in_array($normalizedName, $context['api_included'], true) || $this->isIntermediateResource($relationshipName, $context); - } - - /** - * returns true if the relationship is an intermediate resource. - */ - private function isIntermediateResource(string $relationshipName, array $context): bool - { - $nested = $this->getNestedResources($relationshipName, $context); - - return \count($nested) > 0; + return \in_array($normalizedName, $context['api_included'], true) || \count($this->getIncludedNestedResources($relationshipName, $context)) > 0; } /** - * returns the names of the nested resources from an relationship. + * Returns the names of the nested resources from a path relationship. */ - private function getNestedResources(string $relationshipName, array $context): array + private function getIncludedNestedResources(string $relationshipName, array $context): array { $normalizedName = $this->nameConverter ? $this->nameConverter->normalize($relationshipName, $context['resource_class'], self::FORMAT, $context) : $relationshipName; - $filtered = array_filter($context['api_included'], function (string $included) use ($normalizedName) { + $filtered = array_filter($context['api_included'] ?? [], static function (string $included) use ($normalizedName) { return 0 === strpos($included, $normalizedName.'.'); }); - return array_map(function (string $nested) { + return array_map(static function (string $nested) { return substr($nested, strpos($nested, '.') + 1); }, $filtered); }