diff --git a/Classes/Serializer/Subscriber/AbstractEntitySubscriber.php b/Classes/Serializer/Subscriber/AbstractEntitySubscriber.php index ab530c18..32b352cc 100644 --- a/Classes/Serializer/Subscriber/AbstractEntitySubscriber.php +++ b/Classes/Serializer/Subscriber/AbstractEntitySubscriber.php @@ -48,7 +48,7 @@ public function onPostSerialize(ObjectEvent $event): void $visitor = $event->getVisitor(); $this->addForceEntityProperties($entity, $visitor); - $this->addIri($entity, $visitor); + $this->addIri($entity, $visitor, $event->getType()['name'] ?? ''); } public function onPreDeserialize(PreDeserializeEvent $event): void @@ -79,9 +79,22 @@ protected function addForceEntityProperties(AbstractDomainObject $entity, JsonSe } } - protected function addIri(AbstractDomainObject $entity, JsonSerializationVisitor $visitor): void + /** + * @param string $declaredType Type of the serialized value declared in serializer metadata (e.g. `type` in YAML) + */ + protected function addIri(AbstractDomainObject $entity, JsonSerializationVisitor $visitor, string $declaredType = ''): void { $apiResource = $this->apiResourceRepository->getByEntity($entity); + + // Related object is not an API resource, but YAML metadata declares API resource class as its `type` + if ( + !$apiResource instanceof ApiResource + && $declaredType !== '' + && is_subclass_of($declaredType, get_class($entity)) + ) { + $apiResource = $this->apiResourceRepository->getByEntity($declaredType); + } + if ($apiResource instanceof ApiResource && $apiResource->getMainItemOperation() instanceof ItemOperation) { // @todo should be generated with symfony router $iri = str_replace( diff --git a/Documentation/Integration/Index.rst b/Documentation/Integration/Index.rst index 496643d8..247001d4 100644 --- a/Documentation/Integration/Index.rst +++ b/Documentation/Integration/Index.rst @@ -13,6 +13,15 @@ Models of other extensions (e.g. ``EXT:news``) can be exposed in the API without #. Map your model to the table of the original model with Extbase persistence configuration (``Configuration/Extbase/Persistence/Classes.php``). If your class should also be returned by the original repositories and relations, register it as ``subclasses`` / ``recordType`` as described in TYPO3 documentation. #. Configure serialization of properties of the original class with :ref:`YAML metadata ` - annotations can not be added to classes you do not own. +Extbase creates related objects with the classes used in the original model (e.g. categories of a news are ``\GeorgRinger\News\Domain\Model\Category``). Declare the type of such relation with your API resource class in YAML metadata - related objects are then serialized as that API resource, including its ``@id``: + +.. code-block:: yaml + + GeorgRinger\News\Domain\Model\News: + properties: + categories: + type: 'TYPO3\CMS\Extbase\Persistence\ObjectStorage' + News extension - Example integration ====================================== diff --git a/Documentation/Operations/Index.rst b/Documentation/Operations/Index.rst index 2fa73a99..dfeea760 100644 --- a/Documentation/Operations/Index.rst +++ b/Documentation/Operations/Index.rst @@ -130,12 +130,14 @@ Here is an example of basic response for collection operation (shortened to firs { "title": "[EN] Category 1A", "image": null, - "uid": 1 + "uid": 1, + "@id": "/_api/news/categories/1" }, { "title": "[EN] Category 2A", "image": null, - "uid": 2 + "uid": 2, + "@id": "/_api/news/categories/2" } ], "type": "0", @@ -157,17 +159,19 @@ Here is an example of basic response for collection operation (shortened to firs "tags": [ { "title": "[EN] Tag 1A", - "uid": 1 + "uid": 1, + "@id": "/_api/news/tags/1" }, { "title": "[EN] Tag 4A", - "uid": 4 + "uid": 4, + "@id": "/_api/news/tags/4" } ], "pathSegment": "en-sed-ut-perspiciatis-unde-omnis-iste-natus-error-sit-voluptatem-folder-a", "singleUri": "https://14.t3api.ddev.site/news/en-sed-ut-perspiciatis-unde-omnis-iste-natus-error-sit-voluptatem-folder-a", - "imageThumbnail": "https://14.t3api.ddev.site/fileadmin/_processed_/a/7/csm_test1_6e0c6b2eb1.jpg", - "imageLarge": "https://14.t3api.ddev.site/fileadmin/_processed_/a/7/csm_test1_fe74a17411.jpg", + "imageThumbnail": "https://14.t3api.ddev.site/fileadmin/_processed_/a/7/csm_test1_cb256deee9.jpg", + "imageLarge": "https://14.t3api.ddev.site/fileadmin/_processed_/a/7/csm_test1_f67e7c21bd.jpg", "uid": 1, "@id": "/_api/news/news/1" }, @@ -182,7 +186,8 @@ Here is an example of basic response for collection operation (shortened to firs { "title": "[EN] Category 2A", "image": null, - "uid": 2 + "uid": 2, + "@id": "/_api/news/categories/2" } ], "type": "0", @@ -214,17 +219,19 @@ Here is an example of basic response for collection operation (shortened to firs "tags": [ { "title": "[EN] Tag 1A", - "uid": 1 + "uid": 1, + "@id": "/_api/news/tags/1" }, { "title": "[EN] Tag 2A", - "uid": 2 + "uid": 2, + "@id": "/_api/news/tags/2" } ], "pathSegment": "en-natus-error-sit-voluptatem-folder-a", "singleUri": "https://14.t3api.ddev.site/news/en-natus-error-sit-voluptatem-folder-a", - "imageThumbnail": "https://14.t3api.ddev.site/fileadmin/_processed_/a/7/csm_test1_6e0c6b2eb1.jpg", - "imageLarge": "https://14.t3api.ddev.site/fileadmin/_processed_/a/7/csm_test1_fe74a17411.jpg", + "imageThumbnail": "https://14.t3api.ddev.site/fileadmin/_processed_/a/7/csm_test1_cb256deee9.jpg", + "imageLarge": "https://14.t3api.ddev.site/fileadmin/_processed_/a/7/csm_test1_f67e7c21bd.jpg", "uid": 2, "@id": "/_api/news/news/2" } @@ -286,61 +293,65 @@ because they are useless in single item operation context. Example response for .. code-block:: json { - "title": "[EN] Sed ut perspiciatis unde omnis iste natus error sit voluptatem folder A", - "alternativeTitle": "", - "teaser": "", - "bodytext": "

Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur?

", - "datetime": "2020-05-28T21:20:00.000+02:00", - "author": "", - "authorEmail": "", - "categories": [ - { - "title": "[EN] Category 1A", - "description": "", - "image": null, - "uid": 1 - }, - { - "title": "[EN] Category 2A", - "description": "", - "image": null, - "uid": 2 - } - ], - "related": [], - "relatedFrom": [], - "type": "0", - "falMedia": [ - { - "url": "https://14.t3api.ddev.site/fileadmin/user_upload/test1.jpg", - "uid": 4, - "file": { - "uid": 1, - "name": "test1.jpg", - "mimeType": "image/jpeg", - "size": 42520 - } - } - ], - "internalurl": "", - "externalurl": "", - "istopnews": false, - "tags": [ - { - "title": "[EN] Tag 1A", - "uid": 1 - }, - { - "title": "[EN] Tag 4A", - "uid": 4 - } - ], - "pathSegment": "en-sed-ut-perspiciatis-unde-omnis-iste-natus-error-sit-voluptatem-folder-a", - "singleUri": "https://14.t3api.ddev.site/news/en-sed-ut-perspiciatis-unde-omnis-iste-natus-error-sit-voluptatem-folder-a", - "imageThumbnail": "https://14.t3api.ddev.site/fileadmin/_processed_/a/7/csm_test1_6e0c6b2eb1.jpg", - "imageLarge": "https://14.t3api.ddev.site/fileadmin/_processed_/a/7/csm_test1_fe74a17411.jpg", - "uid": 1, - "@id": "/_api/news/news/1" + "title": "[EN] Sed ut perspiciatis unde omnis iste natus error sit voluptatem folder A", + "alternativeTitle": "", + "teaser": "", + "bodytext": "

Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur?

", + "datetime": "2020-05-28T21:20:00.000+02:00", + "author": "", + "authorEmail": "", + "categories": [ + { + "title": "[EN] Category 1A", + "description": "", + "image": null, + "uid": 1, + "@id": "/_api/news/categories/1" + }, + { + "title": "[EN] Category 2A", + "description": "", + "image": null, + "uid": 2, + "@id": "/_api/news/categories/2" + } + ], + "related": [], + "relatedFrom": [], + "type": "0", + "falMedia": [ + { + "url": "https://14.t3api.ddev.site/fileadmin/user_upload/test1.jpg", + "uid": 4, + "file": { + "uid": 1, + "name": "test1.jpg", + "mimeType": "image/jpeg", + "size": 42520 + } + } + ], + "internalurl": "", + "externalurl": "", + "istopnews": false, + "tags": [ + { + "title": "[EN] Tag 1A", + "uid": 1, + "@id": "/_api/news/tags/1" + }, + { + "title": "[EN] Tag 4A", + "uid": 4, + "@id": "/_api/news/tags/4" + } + ], + "pathSegment": "en-sed-ut-perspiciatis-unde-omnis-iste-natus-error-sit-voluptatem-folder-a", + "singleUri": "https://14.t3api.ddev.site/news/en-sed-ut-perspiciatis-unde-omnis-iste-natus-error-sit-voluptatem-folder-a", + "imageThumbnail": "https://14.t3api.ddev.site/fileadmin/_processed_/a/7/csm_test1_cb256deee9.jpg", + "imageLarge": "https://14.t3api.ddev.site/fileadmin/_processed_/a/7/csm_test1_f67e7c21bd.jpg", + "uid": 1, + "@id": "/_api/news/news/1" } Customizing returned properties diff --git a/Tests/Functional/Dispatcher/RelatedResourceIriDispatcherTest.php b/Tests/Functional/Dispatcher/RelatedResourceIriDispatcherTest.php new file mode 100644 index 00000000..98a48d99 --- /dev/null +++ b/Tests/Functional/Dispatcher/RelatedResourceIriDispatcherTest.php @@ -0,0 +1,59 @@ +`) are declared + * as `CategoryResource` / `ObjectStorage` in serializer YAML metadata of the fixture extension. + */ +class RelatedResourceIriDispatcherTest extends AbstractDispatcherTestCase +{ + protected function setUp(): void + { + parent::setUp(); + $this->importCSVDataSet(__DIR__ . '/../Fixtures/products.csv'); + $this->importCSVDataSet(__DIR__ . '/../Fixtures/categories.csv'); + $this->importCSVDataSet(__DIR__ . '/../Fixtures/tags.csv'); + } + + #[Test] + public function relatedObjectHasIriOfApiResourceDeclaredInSerializerMetadata(): void + { + $product = $this->dispatchProductGet(1); + + self::assertSame('/_api/categories/1', $product['category']['@id'] ?? null); + } + + #[Test] + public function relatedObjectsInObjectStorageHaveIriOfApiResourceDeclaredInSerializerMetadata(): void + { + $product = $this->dispatchProductGet(1); + + self::assertSame(['/_api/tags/1', '/_api/tags/2'], array_column($product['tags'], '@id')); + } + + #[Test] + public function apiResourceItselfKeepsItsIri(): void + { + self::assertSame('/_api/products/1', $this->dispatchProductGet(1)['@id']); + } + + private function dispatchProductGet(int $uid): array + { + return json_decode( + $this->dispatchGet('https://example.com/_api/products/' . $uid), + true, + 512, + JSON_THROW_ON_ERROR + ); + } +} diff --git a/Tests/Functional/Fixtures/Extensions/t3api_functional_test/Classes/Domain/Model/CategoryResource.php b/Tests/Functional/Fixtures/Extensions/t3api_functional_test/Classes/Domain/Model/CategoryResource.php new file mode 100644 index 00000000..0f070f6f --- /dev/null +++ b/Tests/Functional/Fixtures/Extensions/t3api_functional_test/Classes/Domain/Model/CategoryResource.php @@ -0,0 +1,23 @@ + [ + 'tableName' => 'tx_functionaltest_domain_model_category', + ], + TagResource::class => [ + 'tableName' => 'tx_functionaltest_domain_model_tag', + ], +]; diff --git a/Tests/Functional/Fixtures/Extensions/t3api_functional_test/Resources/Private/Serializer/Domain.Model.Product.yml b/Tests/Functional/Fixtures/Extensions/t3api_functional_test/Resources/Private/Serializer/Domain.Model.Product.yml new file mode 100644 index 00000000..94113770 --- /dev/null +++ b/Tests/Functional/Fixtures/Extensions/t3api_functional_test/Resources/Private/Serializer/Domain.Model.Product.yml @@ -0,0 +1,6 @@ +T3apiTests\FunctionalTest\Domain\Model\Product: + properties: + category: + type: 'T3apiTests\FunctionalTest\Domain\Model\CategoryResource' + tags: + type: 'TYPO3\CMS\Extbase\Persistence\ObjectStorage' diff --git a/Tests/Functional/Fixtures/Extensions/t3api_functional_test/ext_localconf.php b/Tests/Functional/Fixtures/Extensions/t3api_functional_test/ext_localconf.php new file mode 100644 index 00000000..753c7dfb --- /dev/null +++ b/Tests/Functional/Fixtures/Extensions/t3api_functional_test/ext_localconf.php @@ -0,0 +1,11 @@ + \TYPO3\CMS\Core\Utility\ExtensionManagementUtility::extPath('t3api_functional_test') + . 'Resources/Private/Serializer', + ] +); diff --git a/Tests/Postman/t3apinews.crud.json b/Tests/Postman/t3apinews.crud.json index 86b4b112..d1e3ed17 100644 --- a/Tests/Postman/t3apinews.crud.json +++ b/Tests/Postman/t3apinews.crud.json @@ -243,7 +243,13 @@ " pm.expect(response.categories[0].uid).to.eql(pm.environment.get('categoryUid'));", " }", ");", - "" + "", + "pm.test(", + " 'Category has IRI of category resource',", + " function() {", + " pm.expect(response.categories[0]['@id']).to.eql(pm.environment.get('singleCategoryEndpoint'));", + " }", + ");" ], "type": "text/javascript" }