diff --git a/.ddev/test/files/src/t3apinews/Resources/Private/Serializer/GeorgRinger.News/Domain.Model.News.yml b/.ddev/test/files/src/t3apinews/Resources/Private/Serializer/GeorgRinger.News/Domain.Model.News.yml index 6a8f4b63..96a4da0f 100644 --- a/.ddev/test/files/src/t3apinews/Resources/Private/Serializer/GeorgRinger.News/Domain.Model.News.yml +++ b/.ddev/test/files/src/t3apinews/Resources/Private/Serializer/GeorgRinger.News/Domain.Model.News.yml @@ -11,7 +11,9 @@ GeorgRinger\News\Domain\Model\News: datetime: groups: [api_get_collection_t3apinews_news, api_get_item_t3apinews_news, api_post_item_t3apinews_news, api_patch_item_t3apinews_news, api_put_item_t3apinews_news] crdate: - groups: [api_post_item_t3apinews_news, api_patch_item_t3apinews_news, api_put_item_t3apinews_news] + groups: [api_get_item_t3apinews_news, api_post_item_t3apinews_news, api_patch_item_t3apinews_news, api_put_item_t3apinews_news] + tstamp: + groups: [api_get_item_t3apinews_news, api_post_item_t3apinews_news, api_patch_item_t3apinews_news, api_put_item_t3apinews_news] author: groups: [api_get_collection_t3apinews_news, api_get_item_t3apinews_news, api_post_item_t3apinews_news, api_patch_item_t3apinews_news, api_put_item_t3apinews_news] authorEmail: diff --git a/Classes/OperationHandler/AbstractOperationHandler.php b/Classes/OperationHandler/AbstractOperationHandler.php index 54a42420..eb8171c3 100644 --- a/Classes/OperationHandler/AbstractOperationHandler.php +++ b/Classes/OperationHandler/AbstractOperationHandler.php @@ -11,6 +11,7 @@ use SourceBroker\T3api\Exception\OperationNotAllowedException; use SourceBroker\T3api\Security\OperationAccessChecker; use SourceBroker\T3api\Serializer\ContextBuilder\DeserializationContextBuilder; +use SourceBroker\T3api\Service\CommonDateFieldsService; use SourceBroker\T3api\Service\SerializerService; use SourceBroker\T3api\Service\SlugService; use SourceBroker\T3api\Service\ValidationService; @@ -21,6 +22,8 @@ abstract class AbstractOperationHandler implements OperationHandlerInterface { protected SlugService $slugService; + protected CommonDateFieldsService $commonDateFieldsService; + public function __construct( protected readonly SerializerService $serializerService, protected readonly ValidationService $validationService, @@ -38,6 +41,15 @@ public function injectSlugService(SlugService $slugService): void $this->slugService = $slugService; } + /** + * Injected by method (not constructor) so that adding new dependency does not break handlers which extend this + * class and override the constructor. + */ + public function injectCommonDateFieldsService(CommonDateFieldsService $commonDateFieldsService): void + { + $this->commonDateFieldsService = $commonDateFieldsService; + } + protected function getRepositoryForOperation(OperationInterface $operation): CommonRepository { return CommonRepository::getInstanceForOperation($operation); diff --git a/Classes/OperationHandler/CollectionPostOperationHandler.php b/Classes/OperationHandler/CollectionPostOperationHandler.php index 5b83082b..62fcebb0 100644 --- a/Classes/OperationHandler/CollectionPostOperationHandler.php +++ b/Classes/OperationHandler/CollectionPostOperationHandler.php @@ -37,6 +37,7 @@ public function handle(OperationInterface $operation, Request $request, array $r $repository->add($object); GeneralUtility::makeInstance(PersistenceManager::class)->persistAll(); $this->slugService->processSlugs($object); + $this->commonDateFieldsService->applyToObject($object); $response = $response ? $response->withStatus(201) : $response; diff --git a/Classes/OperationHandler/ItemPatchOperationHandler.php b/Classes/OperationHandler/ItemPatchOperationHandler.php index 5e7457d0..1680367d 100644 --- a/Classes/OperationHandler/ItemPatchOperationHandler.php +++ b/Classes/OperationHandler/ItemPatchOperationHandler.php @@ -44,6 +44,7 @@ public function handle( $repository->update($object); GeneralUtility::makeInstance(PersistenceManager::class)->persistAll(); $this->slugService->processSlugs($object); + $this->commonDateFieldsService->applyToObject($object); return $object; } diff --git a/Classes/OperationHandler/ItemPutOperationHandler.php b/Classes/OperationHandler/ItemPutOperationHandler.php index 7bc150c8..c1cd78a8 100644 --- a/Classes/OperationHandler/ItemPutOperationHandler.php +++ b/Classes/OperationHandler/ItemPutOperationHandler.php @@ -54,6 +54,7 @@ public function handle( $repository->add($object); GeneralUtility::makeInstance(PersistenceManager::class)->persistAll(); $this->slugService->processSlugs($object); + $this->commonDateFieldsService->applyToObject($object); return $object; } diff --git a/Classes/Service/CommonDateFieldsService.php b/Classes/Service/CommonDateFieldsService.php new file mode 100644 index 00000000..cf1ca031 --- /dev/null +++ b/Classes/Service/CommonDateFieldsService.php @@ -0,0 +1,146 @@ +dataMapFactory->buildDataMap(get_class($object)); + $columnNames = array_filter([ + $dataMap->getCreationDateColumnName(), + $dataMap->getModificationDateColumnName(), + ]); + + if ($columnNames === []) { + return; + } + + // Iterate over class schema, not object values - uninitialized typed properties are not returned by + // `_getProperties()`. + $properties = []; + foreach ($this->reflectionService->getClassSchema($object)->getDomainObjectProperties() as $property) { + $columnMap = $dataMap->getColumnMap($property->getName()); + if ($columnMap !== null && in_array($columnMap->getColumnName(), $columnNames, true)) { + $properties[] = [$property, $columnMap]; + } + } + + if ($properties === []) { + return; + } + + $row = $this->getRow( + $dataMap->getTableName(), + (int)($object->_getProperty(AbstractDomainObject::PROPERTY_LOCALIZED_UID) ?? $object->getUid()), + array_map(static fn(array $item): string => $item[1]->getColumnName(), $properties) + ); + + if ($row === null) { + return; + } + + foreach ($properties as [$property, $columnMap]) { + $value = $row[$columnMap->getColumnName()] ?? null; + if ($value === null) { + continue; + } + + $type = $property->getPrimaryType(); + $className = $type?->getClassName(); + if (in_array($type?->getBuiltinType(), ['int', 'integer'], true)) { + $value = (int)$value; + } elseif ($className !== null && is_subclass_of($className, \DateTimeInterface::class)) { + $value = $this->mapDateTime($value, $columnMap, $className); + } else { + continue; + } + + if ($value !== null || $property->isNullable()) { + $object->_setProperty($property->getName(), $value); + $object->_memorizeCleanState($property->getName()); + } + } + } + + /** + * @param class-string<\DateTimeInterface> $targetType + * @see \TYPO3\CMS\Extbase\Persistence\Generic\Mapper\DataMapper::mapDateTime() + */ + protected function mapDateTime(int|string $value, ColumnMap $columnMap, string $targetType): ?\DateTimeInterface + { + // Feature exists since TYPO3 13 (enabled by default since TYPO3 14) + if ($this->features->isFeatureEnabled('extbase.consistentDateTimeHandling')) { + $dateTime = DateTimeFactory::createFromDatabaseValueAndTCAConfig( + $value, + [ + 'type' => 'datetime', + 'format' => $columnMap->getDateTimeFormat(), + 'dbType' => $columnMap->getDateTimeStorageFormat(), + 'nullable' => $columnMap->isNullable(), + ] + ); + + return $dateTime === null ? null : match ($targetType) { + \DateTimeImmutable::class => $dateTime, + \DateTime::class => \DateTime::createFromImmutable($dateTime), + default => GeneralUtility::makeInstance($targetType, $dateTime->format('Y-m-d H:i:s.v e')), + }; + } + + if (empty($value) || $value === '0000-00-00' || $value === '0000-00-00 00:00:00' || $value === '00:00:00') { + return null; + } + if (!in_array($columnMap->getDateTimeStorageFormat(), QueryHelper::getDateTimeTypes(), true)) { + $value = date('c', (int)$value); + } + + return GeneralUtility::makeInstance($targetType, $value); + } + + /** + * @param string[] $columnNames + */ + protected function getRow(string $tableName, int $uid, array $columnNames): ?array + { + $queryBuilder = $this->connectionPool->getQueryBuilderForTable($tableName); + $queryBuilder->getRestrictions()->removeAll(); + $row = $queryBuilder + ->select(...$columnNames) + ->from($tableName) + ->where($queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($uid, Connection::PARAM_INT))) + ->executeQuery() + ->fetchAssociative(); + + return $row === false ? null : $row; + } +} diff --git a/Documentation/Miscellaneous/Changelog/Index.rst b/Documentation/Miscellaneous/Changelog/Index.rst index 3afb5d5f..96fb2a1f 100644 --- a/Documentation/Miscellaneous/Changelog/Index.rst +++ b/Documentation/Miscellaneous/Changelog/Index.rst @@ -14,6 +14,7 @@ Changelog - Validate pagination parameters like API Platform: invalid ``page`` / ``itemsPerPage`` returns ``400 Bad Request``, ``itemsPerPage=0`` returns only ``hydra:totalItems``, empty collection has one page in ``hydra:view``. See :ref:`pagination`. [`commit `__] - Add ``@id`` to related objects when their API resource class is set as ``type`` in YAML metadata. See :ref:`integration`. [`commit `__] - Add ``version`` and ``providesPackages`` to ``composer.json`` (TYPO3 14.3 classic mode metadata). +- Return creation and modification date (TCA ``ctrl.crdate`` and ``ctrl.tstamp``) stored by TYPO3 in response of POST, PUT and PATCH operations. Fixes HTTP 500 after creating record without creation date when getter does not allow ``null``. - Add TYPO3 14 support. [`commit `__] - Add PHP 8.5 support. [`commit `__] - Add tag-based :ref:`response cache ` for GET endpoints. [`commit `__] diff --git a/Tests/Functional/Dispatcher/CommonDateFieldsDispatcherTest.php b/Tests/Functional/Dispatcher/CommonDateFieldsDispatcherTest.php new file mode 100644 index 00000000..920c99f3 --- /dev/null +++ b/Tests/Functional/Dispatcher/CommonDateFieldsDispatcherTest.php @@ -0,0 +1,91 @@ +importCSVDataSet(__DIR__ . '/../Fixtures/articles.csv'); + } + + #[Test] + public function postReturnsDatesStoredInDatabase(): void + { + $article = $this->dispatch('POST', 'https://example.com/_api/articles', ['title' => 'New article']); + + self::assertNotNull($article['crdate']); + self::assertNotNull($article['tstamp']); + $this->assertDatesAreSameAsInGetResponse($article); + } + + #[Test] + public function patchReturnsUpdatedModificationDate(): void + { + $tstampBeforeUpdate = $this->dispatch('GET', 'https://example.com/_api/articles/1')['tstamp']; + $article = $this->dispatch('PATCH', 'https://example.com/_api/articles/1', ['title' => 'Changed title']); + + self::assertNotSame($tstampBeforeUpdate, $article['tstamp']); + $this->assertDatesAreSameAsInGetResponse($article); + } + + #[Test] + public function patchReturnsZeroCreationDateMappedLikeExtbase(): void + { + $article = $this->dispatch('PATCH', 'https://example.com/_api/articles/1', ['title' => 'Changed title']); + + self::assertNull($article['crdate'] ?? null); + $this->assertDatesAreSameAsInGetResponse($article); + } + + private function assertDatesAreSameAsInGetResponse(array $article): void + { + // Drop objects held by persistence session, so GET maps the record from the database + $this->get(PersistenceManagerInterface::class)->clearState(); + $storedArticle = $this->dispatch('GET', 'https://example.com/_api/articles/' . $article['uid']); + + self::assertSame($storedArticle['crdate'] ?? null, $article['crdate'] ?? null); + self::assertSame($storedArticle['tstamp'] ?? null, $article['tstamp'] ?? null); + } + + private function dispatch(string $method, string $url, ?array $body = null): array + { + $symfonyRequest = SymfonyRequest::create( + $url, + $method, + [], + [], + [], + ['CONTENT_TYPE' => 'application/json'], + $body === null ? null : json_encode($body, JSON_THROW_ON_ERROR) + ); + $response = new Response(); + + return json_decode( + $this->get(Bootstrap::class)->processOperationByRequest( + (new RequestContext())->fromRequest($symfonyRequest), + $symfonyRequest, + $response + ), + true, + 512, + JSON_THROW_ON_ERROR + ); + } +} diff --git a/Tests/Functional/Fixtures/Extensions/t3api_functional_test/Classes/Domain/Model/Article.php b/Tests/Functional/Fixtures/Extensions/t3api_functional_test/Classes/Domain/Model/Article.php new file mode 100644 index 00000000..585a44e2 --- /dev/null +++ b/Tests/Functional/Fixtures/Extensions/t3api_functional_test/Classes/Domain/Model/Article.php @@ -0,0 +1,71 @@ +title; + } + + public function setTitle(string $title): void + { + $this->title = $title; + } + + public function getCrdate(): ?\DateTime + { + return $this->crdate; + } + + public function getTstamp(): \DateTime + { + return $this->tstamp; + } +} diff --git a/Tests/Functional/Fixtures/Extensions/t3api_functional_test/Configuration/TCA/tx_functionaltest_domain_model_article.php b/Tests/Functional/Fixtures/Extensions/t3api_functional_test/Configuration/TCA/tx_functionaltest_domain_model_article.php new file mode 100644 index 00000000..7b955126 --- /dev/null +++ b/Tests/Functional/Fixtures/Extensions/t3api_functional_test/Configuration/TCA/tx_functionaltest_domain_model_article.php @@ -0,0 +1,33 @@ + [ + 'label' => 'Title', + 'config' => [ + 'type' => 'input', + ], + ], + // Extbase maps only fields configured in TCA columns + 'crdate' => [ + 'label' => 'Creation date', + 'config' => [ + 'type' => 'datetime', + ], + ], + 'tstamp' => [ + 'label' => 'Modification date', + 'config' => [ + 'type' => 'datetime', + ], + ], + ], + 'title' +); +$tca['ctrl']['crdate'] = 'crdate'; +$tca['ctrl']['tstamp'] = 'tstamp'; + +return $tca; diff --git a/Tests/Functional/Fixtures/Extensions/t3api_functional_test/Resources/Private/Serializer/Domain.Model.Article.yml b/Tests/Functional/Fixtures/Extensions/t3api_functional_test/Resources/Private/Serializer/Domain.Model.Article.yml new file mode 100644 index 00000000..edcc6434 --- /dev/null +++ b/Tests/Functional/Fixtures/Extensions/t3api_functional_test/Resources/Private/Serializer/Domain.Model.Article.yml @@ -0,0 +1,10 @@ +T3apiTests\FunctionalTest\Domain\Model\Article: + properties: + uid: + groups: [api_article] + title: + groups: [api_article] + crdate: + groups: [api_article] + tstamp: + groups: [api_article] diff --git a/Tests/Functional/Fixtures/Extensions/t3api_functional_test/ext_tables.sql b/Tests/Functional/Fixtures/Extensions/t3api_functional_test/ext_tables.sql index 119d6520..6817b949 100644 --- a/Tests/Functional/Fixtures/Extensions/t3api_functional_test/ext_tables.sql +++ b/Tests/Functional/Fixtures/Extensions/t3api_functional_test/ext_tables.sql @@ -30,3 +30,7 @@ CREATE TABLE tx_functionaltest_product_tag_mm ( KEY uid_local (uid_local), KEY uid_foreign (uid_foreign) ); + +CREATE TABLE tx_functionaltest_domain_model_article ( + title varchar(255) DEFAULT '' NOT NULL +); diff --git a/Tests/Functional/Fixtures/articles.csv b/Tests/Functional/Fixtures/articles.csv new file mode 100644 index 00000000..2a75c11a --- /dev/null +++ b/Tests/Functional/Fixtures/articles.csv @@ -0,0 +1,3 @@ +"tx_functionaltest_domain_model_article",,,,, +,"uid","pid","title","crdate","tstamp" +,1,0,"Article without creation date",0,1577836800 diff --git a/Tests/Postman/t3apinews.crud.json b/Tests/Postman/t3apinews.crud.json index 5df8bd93..50e0c872 100644 --- a/Tests/Postman/t3apinews.crud.json +++ b/Tests/Postman/t3apinews.crud.json @@ -171,6 +171,8 @@ " }", ");", "", + "pm.environment.set('newsCrdate', response.crdate);", + "pm.environment.set('newsTstamp', response.tstamp);", "pm.environment.set('newsUid', response.uid);", "pm.environment.set('singleNewsEndpoint', response['@id']);", "", @@ -180,7 +182,14 @@ " pm.expect(response.pathSegment).to.eql('this-is-an-example-news');", " }", ");", - "" + "", + "pm.test(", + " 'Creation and modification date are returned',", + " function() {", + " pm.expect(response.crdate).to.be.a('string');", + " pm.expect(response.tstamp).to.be.a('string');", + " }", + ");" ], "type": "text/javascript" } @@ -198,7 +207,7 @@ ], "body": { "mode": "raw", - "raw": "{\n\t\"title\": \"This is an example news\",\n\t\"alternativeTitle\": \"Alt title\",\n\t\"crdate\": \"2019-12-27T16:58:23.063Z\",\n\t\"datetime\": \"2019-12-27T16:58:23.063Z\",\n\t\"categories\": [{{categoryUid}}],\n\t\"bodytext\": \"Here is the body\"\n}", + "raw": "{\n\t\"title\": \"This is an example news\",\n\t\"alternativeTitle\": \"Alt title\",\n\t\"datetime\": \"2019-12-27T16:58:23.063Z\",\n\t\"categories\": [{{categoryUid}}],\n\t\"bodytext\": \"Here is the body\"\n}", "options": { "raw": { "language": "json" @@ -257,6 +266,20 @@ " function() {", " pm.expect(response.categories[0]['@id']).to.eql(pm.environment.get('singleCategoryEndpoint'));", " }", + ");", + "", + "pm.test(", + " 'Creation date is same as in response of write operation',", + " function() {", + " pm.expect(response.crdate).to.eql(pm.environment.get('newsCrdate'));", + " }", + ");", + "", + "pm.test(", + " 'Modification date is same as in response of write operation',", + " function() {", + " pm.expect(response.tstamp).to.eql(pm.environment.get('newsTstamp'));", + " }", ");" ], "type": "text/javascript" @@ -440,7 +463,8 @@ " pm.response.to.have.status(200);", " }", ");", - "" + "", + "pm.environment.set('newsTstamp', pm.response.json().tstamp);" ], "type": "text/javascript" } @@ -512,7 +536,20 @@ " pm.expect(response.pathSegment).to.eql('this-is-an-example-news');", " }", ");", - "" + "", + "pm.test(", + " 'Creation date is same as in response of write operation',", + " function() {", + " pm.expect(response.crdate).to.eql(pm.environment.get('newsCrdate'));", + " }", + ");", + "", + "pm.test(", + " 'Modification date is same as in response of write operation',", + " function() {", + " pm.expect(response.tstamp).to.eql(pm.environment.get('newsTstamp'));", + " }", + ");" ], "type": "text/javascript" } @@ -551,7 +588,10 @@ " pm.response.to.have.status(200);", " }", ");", - "" + "", + "const response = pm.response.json();", + "pm.environment.set('newsCrdate', response.crdate);", + "pm.environment.set('newsTstamp', response.tstamp);" ], "type": "text/javascript" } @@ -623,7 +663,20 @@ " pm.expect(response.pathSegment).to.eql('this-is-even-new-title');", " }", ");", - "" + "", + "pm.test(", + " 'Creation date is same as in response of write operation',", + " function() {", + " pm.expect(response.crdate).to.eql(pm.environment.get('newsCrdate'));", + " }", + ");", + "", + "pm.test(", + " 'Modification date is same as in response of write operation',", + " function() {", + " pm.expect(response.tstamp).to.eql(pm.environment.get('newsTstamp'));", + " }", + ");" ], "type": "text/javascript" } diff --git a/phpstan.neon b/phpstan.neon index d19ef13a..2e8f1b3d 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -32,3 +32,13 @@ parameters: - message: "#Call to method getRequest\\(\\) on an unknown class TYPO3\\\\CMS\\\\Frontend\\\\Event\\\\BeforePageCacheIdentifierIsHashedEvent#" reportUnmatched: false + + # @todo This ignoreError can be removed when support for TYPO3 v12 is dropped + - + message: "#Call to an undefined method TYPO3\\\\CMS\\\\Extbase\\\\Persistence\\\\Generic\\\\Mapper\\\\ColumnMap::(getDateTimeFormat|isNullable)\\(\\)#" + reportUnmatched: false + + # @todo This ignoreError can be removed when support for TYPO3 v12 is dropped + - + message: "#on an unknown class TYPO3\\\\CMS\\\\Core\\\\Domain\\\\DateTimeFactory#" + reportUnmatched: false