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
12 changes: 12 additions & 0 deletions Classes/OperationHandler/AbstractOperationHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,15 @@
use SourceBroker\T3api\Security\OperationAccessChecker;
use SourceBroker\T3api\Serializer\ContextBuilder\DeserializationContextBuilder;
use SourceBroker\T3api\Service\SerializerService;
use SourceBroker\T3api\Service\SlugService;
use SourceBroker\T3api\Service\ValidationService;
use Symfony\Component\HttpFoundation\Request;
use TYPO3\CMS\Extbase\DomainObject\AbstractDomainObject;

abstract class AbstractOperationHandler implements OperationHandlerInterface
{
protected SlugService $slugService;

public function __construct(
protected readonly SerializerService $serializerService,
protected readonly ValidationService $validationService,
Expand All @@ -26,6 +29,15 @@ public function __construct(
protected readonly EventDispatcherInterface $eventDispatcher
) {}

/**
* Injected by method (not constructor) so that adding new dependency does not break handlers which extend this
* class and override the constructor.
*/
public function injectSlugService(SlugService $slugService): void
{
$this->slugService = $slugService;
}

protected function getRepositoryForOperation(OperationInterface $operation): CommonRepository
{
return CommonRepository::getInstanceForOperation($operation);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ public function handle(OperationInterface $operation, Request $request, array $r
$this->validationService->validateObject($object);
$repository->add($object);
GeneralUtility::makeInstance(PersistenceManager::class)->persistAll();
$this->slugService->processSlugs($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 @@ -43,6 +43,7 @@ public function handle(
$this->validationService->validateObject($object);
$repository->update($object);
GeneralUtility::makeInstance(PersistenceManager::class)->persistAll();
$this->slugService->processSlugs($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 @@ -53,6 +53,7 @@ public function handle(
$this->validationService->validateObject($object);
$repository->add($object);
GeneralUtility::makeInstance(PersistenceManager::class)->persistAll();
$this->slugService->processSlugs($object);

return $object;
}
Expand Down
135 changes: 135 additions & 0 deletions Classes/Service/SlugService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
<?php

declare(strict_types=1);

namespace SourceBroker\T3api\Service;

use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\DataHandling\Model\RecordStateFactory;
use TYPO3\CMS\Core\DataHandling\SlugHelper;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Extbase\DomainObject\AbstractDomainObject;
use TYPO3\CMS\Extbase\Persistence\Generic\Mapper\DataMapFactory;

/**
* Processes TCA fields of type `slug` the same way DataHandler does: empty value is generated
* from `generatorOptions`, non-empty value is sanitized, and then `eval` uniqueness rules
* (`unique`, `uniqueInSite`, `uniqueInPid`) are applied.
*
* Has to be called after the object is persisted, so the full record (uid, pid, language) is
* available for generation and uniqueness checks.
*/
class SlugService
{
public function __construct(
private readonly DataMapFactory $dataMapFactory,
private readonly ConnectionPool $connectionPool
) {}

public function processSlugs(AbstractDomainObject $object): void
{
$dataMap = $this->dataMapFactory->buildDataMap(get_class($object));
$tableName = $dataMap->getTableName();
$slugFields = $this->getSlugFields($tableName);

if ($slugFields === []) {
return;
}

$uid = (int)($object->_getProperty(AbstractDomainObject::PROPERTY_LOCALIZED_UID) ?? $object->getUid());
$record = $this->getRecord($tableName, $uid);

if ($record === null) {
return;
}

$changedValues = [];
foreach ($slugFields as $fieldName => $fieldConfig) {
$value = (string)($record[$fieldName] ?? '');
$processedValue = $this->processSlug($tableName, $fieldName, $fieldConfig, $value, $record);
if ($processedValue !== $value) {
$changedValues[$fieldName] = $processedValue;
$record[$fieldName] = $processedValue;
}
}

if ($changedValues === []) {
return;
}

$this->connectionPool
->getConnectionForTable($tableName)
->update($tableName, $changedValues, ['uid' => $uid]);

foreach (array_keys($object->_getProperties()) as $propertyName) {
$columnName = $dataMap->getColumnMap($propertyName)?->getColumnName();
if ($columnName !== null && array_key_exists($columnName, $changedValues)) {
$object->_setProperty($propertyName, $changedValues[$columnName]);
$object->_memorizeCleanState($propertyName);
}
}
}

/**
* @see \TYPO3\CMS\Core\DataHandling\DataHandler::checkValueForSlug()
*/
protected function processSlug(
string $tableName,
string $fieldName,
array $fieldConfig,
string $value,
array $record
): string {
$pid = (int)$record['pid'];
$helper = GeneralUtility::makeInstance(SlugHelper::class, $tableName, $fieldName, $fieldConfig);
$value = $value === '' ? $helper->generate($record, $pid) : $helper->sanitize($value);

$evalCodes = GeneralUtility::trimExplode(',', (string)($fieldConfig['eval'] ?? ''), true);
if ($evalCodes === []) {
return $value;
}

$state = RecordStateFactory::forName($tableName)->fromArray($record, $pid, (int)$record['uid']);
if (in_array('unique', $evalCodes, true)) {
$value = $helper->buildSlugForUniqueInTable($value, $state);
}
if (in_array('uniqueInSite', $evalCodes, true)) {
$value = $helper->buildSlugForUniqueInSite($value, $state);
}
if (in_array('uniqueInPid', $evalCodes, true)) {
$value = $helper->buildSlugForUniqueInPid($value, $state);
}

return $value;
}

/**
* @return array<string, array> Field configurations indexed by field name
*/
protected function getSlugFields(string $tableName): array
{
$slugFields = [];
foreach ($GLOBALS['TCA'][$tableName]['columns'] ?? [] as $fieldName => $column) {
if (($column['config']['type'] ?? '') === 'slug') {
$slugFields[$fieldName] = $column['config'];
}
}

return $slugFields;
}

protected function getRecord(string $tableName, int $uid): ?array
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable($tableName);
$queryBuilder->getRestrictions()->removeAll();
$record = $queryBuilder
->select('*')
->from($tableName)
->where($queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($uid, Connection::PARAM_INT)))
->executeQuery()
->fetchAssociative();

return $record === false ? null : $record;
}
}
1 change: 1 addition & 0 deletions Documentation/Miscellaneous/Changelog/Index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ Changelog
- [!!!] Embed ``goldspecdigital/oooas`` library (PHP 8.5 compatibility). The package is no longer a dependency, namespace ``GoldSpecDigital\ObjectOrientedOAS`` is renamed to ``SourceBroker\T3api\OpenApi``. Update imports in custom filters implementing ``OpenApiSupportingFilterInterface`` and custom :ref:`collection response classes <customization_collection-response-schema>`. [`commit <https://github.com/sourcebroker/t3api/commit/9d63ab0>`__]
- [!!!] ``@T3api\Serializer\Type\PasswordHash`` returns ``null`` on serialization instead of the stored password hash. Use ``@T3api\Serializer\Type\PasswordHash(serialize=true)`` to return the hash as before, see :ref:`serialization_handlers_password-hash`. [`commit <https://github.com/sourcebroker/t3api/commit/b08a512>`__]
- [!!!] Refactor expression evaluation in access checkers. Protected method ``AbstractAccessChecker::getExpressionLanguageResolver()`` is removed, use ``evaluateExpression()`` instead. [`commit <https://github.com/sourcebroker/t3api/commit/77e4c0e>`__]
- [!!!] Generate and validate :ref:`slug fields <operations_slugs>` according to TCA configuration on POST, PUT and PATCH operations. Slug values sent by the client are sanitized and made unique.
- Skip cHash validation for API query arguments (filters, pagination, ordering) when ``cacheHash.enforceValidation`` is enabled. [`commit <https://github.com/sourcebroker/t3api/commit/6ad426e>`__]
- 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>`__]
Expand Down
21 changes: 21 additions & 0 deletions Documentation/Operations/Index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,27 @@ T3api supports all REST methods. Method of the operation is set with ``method``
.. important::
Notice that ``POST`` operation is a type of collection operations

.. _operations_slugs:

Slug fields
=============

After ``POST``, ``PUT`` and ``PATCH`` operations T3api processes all TCA fields of type ``slug`` of the persisted
record the same way TYPO3 DataHandler does:

- empty slug is generated by TYPO3 according to the field configuration (``generatorOptions``, ``fallbackCharacter``,
``prependSlash``, ...),
- non-empty slug is sanitized,
- uniqueness rules from ``eval`` (``unique``, ``uniqueInSite``, ``uniqueInPid``) are applied.

It is done for the TCA field even if it is not mapped to any property of the entity. Thanks to that, client
does not need to send the slug at all. For example, creating a news with ``"title": "This is an example news"`` results
in ``pathSegment`` equal to ``this-is-an-example-news`` (or ``this-is-an-example-news-1`` if the slug is already taken).

Same as in the TYPO3 backend, the slug is not regenerated when other fields change (e.g. ``title`` in ``PATCH``).
To regenerate it, send an empty value for the slug property. ``PUT`` resets all properties not included in the
request, so the slug is regenerated if it is not sent.

Response of GET collection operation
========================================

Expand Down
Loading
Loading