Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
12 changes: 12 additions & 0 deletions Classes/OperationHandler/AbstractOperationHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
1 change: 1 addition & 0 deletions Classes/OperationHandler/ItemPatchOperationHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
1 change: 1 addition & 0 deletions Classes/OperationHandler/ItemPutOperationHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
146 changes: 146 additions & 0 deletions Classes/Service/CommonDateFieldsService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
<?php

declare(strict_types=1);

namespace SourceBroker\T3api\Service;

use TYPO3\CMS\Core\Configuration\Features;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Database\Query\QueryHelper;
use TYPO3\CMS\Core\Domain\DateTimeFactory;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Extbase\DomainObject\AbstractDomainObject;
use TYPO3\CMS\Extbase\Persistence\Generic\Mapper\ColumnMap;
use TYPO3\CMS\Extbase\Persistence\Generic\Mapper\DataMapFactory;
use TYPO3\CMS\Extbase\Reflection\ReflectionService;

/**
* Extbase sets creation and modification date (TCA `ctrl.crdate` and `ctrl.tstamp`) only in the database row
* (@see \TYPO3\CMS\Extbase\Persistence\Generic\Backend::addCommonDateFieldsToRow()), so the persisted object
* still holds old values (e.g. `null` for new object). This service copies stored values to the object, mapped
* the same way as Extbase maps them when the object is loaded from the database, so the response is the same
* as the response of subsequent GET.
*
* @see \TYPO3\CMS\Extbase\Persistence\Generic\Mapper\DataMapper::thawProperties()
*/
class CommonDateFieldsService
{
public function __construct(
private readonly DataMapFactory $dataMapFactory,
private readonly ReflectionService $reflectionService,
private readonly ConnectionPool $connectionPool,
private readonly Features $features
) {}

public function applyToObject(AbstractDomainObject $object): void
{
$dataMap = $this->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;
}
}
1 change: 1 addition & 0 deletions Documentation/Miscellaneous/Changelog/Index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 <https://github.com/sourcebroker/t3api/commit/50d1b28>`__]
- Add ``@id`` to related objects when their API resource class is set as ``type`` in YAML metadata. See :ref:`integration`. [`commit <https://github.com/sourcebroker/t3api/commit/fbd795a>`__]
- 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 <https://github.com/sourcebroker/t3api/commit/5de5826>`__]
- Add PHP 8.5 support. [`commit <https://github.com/sourcebroker/t3api/commit/81878f4>`__]
- Add tag-based :ref:`response cache <response-cache>` for GET endpoints. [`commit <https://github.com/sourcebroker/t3api/commit/c35a0ab>`__]
Expand Down
91 changes: 91 additions & 0 deletions Tests/Functional/Dispatcher/CommonDateFieldsDispatcherTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
<?php

declare(strict_types=1);

namespace SourceBroker\T3api\Tests\Functional\Dispatcher;

use PHPUnit\Framework\Attributes\Test;
use SourceBroker\T3api\Dispatcher\Bootstrap;
use Symfony\Component\HttpFoundation\Request as SymfonyRequest;
use Symfony\Component\Routing\RequestContext;
use TYPO3\CMS\Core\Http\Response;
use TYPO3\CMS\Extbase\Persistence\PersistenceManagerInterface;

/**
* Extbase sets creation and modification date only in the database row. Response of write operations
* has to contain these dates mapped the same way as in response of subsequent GET.
*
* Fixture: `Article` has typed, not initialized `crdate` (nullable) and `tstamp` (not nullable) properties.
*/
class CommonDateFieldsDispatcherTest extends AbstractDispatcherTestCase
{
protected function setUp(): void
{
parent::setUp();
$this->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
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<?php

declare(strict_types=1);

namespace T3apiTests\FunctionalTest\Domain\Model;

use SourceBroker\T3api\Annotation\ApiResource;
use TYPO3\CMS\Extbase\DomainObject\AbstractEntity;

/**
* @ApiResource(
* collectionOperations={
* "post": {
* "method": "POST",
* "path": "/articles",
* "normalizationContext": {
* "groups": {"api_article"}
* }
* }
* },
* itemOperations={
* "get": {
* "path": "/articles/{id}",
* "normalizationContext": {
* "groups": {"api_article"}
* }
* },
* "patch": {
* "method": "PATCH",
* "path": "/articles/{id}",
* "normalizationContext": {
* "groups": {"api_article"}
* }
* }
* }
* )
*/
class Article extends AbstractEntity
{
protected string $title = '';

/**
* Typed and not initialized on purpose - such properties are not returned by `_getProperties()`.
*/
protected ?\DateTime $crdate;

/**
* Typed, not initialized and not nullable on purpose - getter fails when value is not set.
*/
protected \DateTime $tstamp;

public function getTitle(): string
{
return $this->title;
}

public function setTitle(string $title): void
{
$this->title = $title;
}

public function getCrdate(): ?\DateTime
{
return $this->crdate;
}

public function getTstamp(): \DateTime
{
return $this->tstamp;
}
}
Loading
Loading