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 d2850d7ea49..a7d07dbf3b5 100644 --- a/features/jsonapi/related-resouces-inclusion.feature +++ b/features/jsonapi/related-resouces-inclusion.feature @@ -391,6 +391,480 @@ Feature: JSON API Inclusion of Related Resources } """ + @createSchema + Scenario: Request inclusion of resources from path + 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 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 + }, + "relationships": { + "relatedDummy": { + "data": { + "type": "RelatedDummy", + "id": "/related_dummies/1" + } + }, + "relatedDummies": { + "data": [ + { + "type": "RelatedDummy", + "id": "/related_dummies/1" + }, + { + "type": "RelatedDummy", + "id": "/related_dummies/2" + } + ] + } + } + }, + "included": [ + { + "id": "/related_dummies/1", + "type": "RelatedDummy", + "attributes": { + "_id": 1, + "name": "Hello", + "symfony": "symfony", + "dummyDate": null, + "dummyBoolean": null, + "embeddedDummy": { + "dummyName": null, + "dummyBoolean": null, + "dummyDate": null, + "dummyFloat": null, + "dummyPrice": null, + "symfony": null + }, + "age": null + }, + "relationships": { + "thirdLevel": { + "data": { + "type": "ThirdLevel", + "id": "/third_levels/1" + } + } + } + }, + { + "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 + 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 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 + }, + "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 + }, + "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": "/related_dummies/2", + "type": "RelatedDummy", + "attributes": { + "_id": 2, + "name": "RelatedDummy #2", + "symfony": "symfony", + "dummyDate": null, + "dummyBoolean": null, + "embeddedDummy": { + "dummyName": null, + "dummyBoolean": null, + "dummyDate": null, + "dummyFloat": null, + "dummyPrice": null, + "symfony": null + }, + "age": null + }, + "relationships": { + "thirdLevel": { + "data": { + "type": "ThirdLevel", + "id": "/third_levels/2" + } + } + } + }, + { + "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, + "dummyDate": null, + "dummyFloat": null, + "dummyPrice": null, + "symfony": null + }, + "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 + 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 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 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 + }, + "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 + }, + "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": "/related_dummies/2", + "type": "RelatedDummy", + "attributes": { + "_id": 2, + "name": "RelatedDummy #2", + "symfony": "symfony", + "dummyDate": null, + "dummyBoolean": null, + "embeddedDummy": { + "dummyName": null, + "dummyBoolean": null, + "dummyDate": null, + "dummyFloat": null, + "dummyPrice": null, + "symfony": null + }, + "age": null + }, + "relationships": { + "thirdLevel": { + "data": { + "type": "ThirdLevel", + "id": "/third_levels/1" + } + } + } + }, + { + "id": "/related_dummies/3", + "type": "RelatedDummy", + "attributes": { + "_id": 3, + "name": "RelatedDummy #3", + "symfony": "symfony", + "dummyDate": null, + "dummyBoolean": null, + "embeddedDummy": { + "dummyName": null, + "dummyBoolean": null, + "dummyDate": null, + "dummyFloat": null, + "dummyPrice": null, + "symfony": null + }, + "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 +1148,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 equal to: + """ + { + "links": { + "self": "/dummies?include=relatedDummy.thirdLevel" + }, + "meta": { + "totalItems": 3, + "itemsPerPage": 3, + "currentPage": 1 + }, + "data": [ + { + "id": "/dummies/1", + "type": "Dummy", + "attributes": { + "_id": 1, + "name": "Dummy #1", + "alias": "Alias #2", + "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" + } + } + } + }, + { + "id": "/dummies/2", + "type": "Dummy", + "attributes": { + "_id": 2, + "name": "Dummy #2", + "alias": "Alias #1", + "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/2" + } + } + } + }, + { + "id": "/dummies/3", + "type": "Dummy", + "attributes": { + "_id": 3, + "name": "Dummy #3", + "alias": "Alias #0", + "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/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 + }, + "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": "/related_dummies/2", + "type": "RelatedDummy", + "attributes": { + "_id": 2, + "name": "RelatedDummy #2", + "symfony": "symfony", + "dummyDate": null, + "dummyBoolean": null, + "embeddedDummy": { + "dummyName": null, + "dummyBoolean": null, + "dummyDate": null, + "dummyFloat": null, + "dummyPrice": null, + "symfony": null + }, + "age": null + }, + "relationships": { + "thirdLevel": { + "data": { + "type": "ThirdLevel", + "id": "/third_levels/2" + } + } + } + }, + { + "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, + "dummyDate": null, + "dummyFloat": null, + "dummyPrice": null, + "symfony": null + }, + "age": null + }, + "relationships": { + "thirdLevel": { + "data": { + "type": "ThirdLevel", + "id": "/third_levels/3" + } + } + } + }, + { + "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 2beb8cab51a..bf588d8c02f 100644 --- a/src/JsonApi/Serializer/ItemNormalizer.php +++ b/src/JsonApi/Serializer/ItemNormalizer.php @@ -94,6 +94,10 @@ public function normalize($object, $format = null, array $context = []) $allRelationshipsData = $this->getComponents($object, $format, $context)['relationships']; $populatedRelationContext = $context; $relationshipsData = $this->getPopulatedRelations($object, $format, $populatedRelationContext, $allRelationshipsData); + + // Do not include primary resources + $context['api_included_resources'] = [$context['iri']]; + $includedResourcesData = $this->getRelatedResources($object, $format, $context, $allRelationshipsData); $resourceData = [ @@ -370,32 +374,78 @@ 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; + $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']) { - $included[] = $attributeValue['data']; - - continue; + $attributeValues = [$attributeValue]; } - // Many to many relationship - foreach ($attributeValue as $attributeValueElement) { + + foreach ($attributeValues 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 included. + */ + private function addIncluded(array $data, array &$included, array &$context): void + { + 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']; + } + } + + /** + * 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) || \count($this->getIncludedNestedResources($relationshipName, $context)) > 0; + } + + /** + * Returns the names of the nested resources from a path relationship. + */ + 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'] ?? [], static function (string $included) use ($normalizedName) { + return 0 === strpos($included, $normalizedName.'.'); + }); + + return array_map(static 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 */