diff --git a/Classes/OperationHandler/AbstractOperationHandler.php b/Classes/OperationHandler/AbstractOperationHandler.php index 710a5dcc..54a42420 100644 --- a/Classes/OperationHandler/AbstractOperationHandler.php +++ b/Classes/OperationHandler/AbstractOperationHandler.php @@ -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, @@ -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); diff --git a/Classes/OperationHandler/CollectionPostOperationHandler.php b/Classes/OperationHandler/CollectionPostOperationHandler.php index 675e8051..5b83082b 100644 --- a/Classes/OperationHandler/CollectionPostOperationHandler.php +++ b/Classes/OperationHandler/CollectionPostOperationHandler.php @@ -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; diff --git a/Classes/OperationHandler/ItemPatchOperationHandler.php b/Classes/OperationHandler/ItemPatchOperationHandler.php index 15ff4731..5e7457d0 100644 --- a/Classes/OperationHandler/ItemPatchOperationHandler.php +++ b/Classes/OperationHandler/ItemPatchOperationHandler.php @@ -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; } diff --git a/Classes/OperationHandler/ItemPutOperationHandler.php b/Classes/OperationHandler/ItemPutOperationHandler.php index 1c85d7bf..7bc150c8 100644 --- a/Classes/OperationHandler/ItemPutOperationHandler.php +++ b/Classes/OperationHandler/ItemPutOperationHandler.php @@ -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; } diff --git a/Classes/Service/SlugService.php b/Classes/Service/SlugService.php new file mode 100644 index 00000000..c10ddd66 --- /dev/null +++ b/Classes/Service/SlugService.php @@ -0,0 +1,135 @@ +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 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; + } +} diff --git a/Documentation/Miscellaneous/Changelog/Index.rst b/Documentation/Miscellaneous/Changelog/Index.rst index 037fff3b..3afb5d5f 100644 --- a/Documentation/Miscellaneous/Changelog/Index.rst +++ b/Documentation/Miscellaneous/Changelog/Index.rst @@ -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 `. [`commit `__] - [!!!] ``@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 `__] - [!!!] Refactor expression evaluation in access checkers. Protected method ``AbstractAccessChecker::getExpressionLanguageResolver()`` is removed, use ``evaluateExpression()`` instead. [`commit `__] +- [!!!] Generate and validate :ref:`slug fields ` 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 `__] - 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 `__] diff --git a/Documentation/Operations/Index.rst b/Documentation/Operations/Index.rst index dfeea760..4c71c9b6 100644 --- a/Documentation/Operations/Index.rst +++ b/Documentation/Operations/Index.rst @@ -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 ======================================== diff --git a/Tests/Postman/t3apinews.crud.json b/Tests/Postman/t3apinews.crud.json index d1e3ed17..5df8bd93 100644 --- a/Tests/Postman/t3apinews.crud.json +++ b/Tests/Postman/t3apinews.crud.json @@ -172,7 +172,15 @@ ");", "", "pm.environment.set('newsUid', response.uid);", - "pm.environment.set('singleNewsEndpoint', response['@id']);" + "pm.environment.set('singleNewsEndpoint', response['@id']);", + "", + "pm.test(", + " 'Path segment is generated from title',", + " function() {", + " pm.expect(response.pathSegment).to.eql('this-is-an-example-news');", + " }", + ");", + "" ], "type": "text/javascript" } @@ -190,7 +198,7 @@ ], "body": { "mode": "raw", - "raw": "{\n\t\"title\": \"This is an example news\",\n\t\"alternativeTitle\": \"Alt title\",\n\t\"pid\": 3,\n\t\"pathSegment\":\"this_is_an_example_news\",\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\"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}", "options": { "raw": { "language": "json" @@ -274,6 +282,107 @@ }, "response": [] }, + { + "name": "Create news with the same title", + "event": [ + { + "listen": "test", + "script": { + "id": "b5692cb5-afa7-4dc1-8297-13922713e550", + "exec": [ + "const response = pm.response.json();", + "", + "pm.test(", + " 'Status code is 201',", + " function () {", + " pm.response.to.have.status(201);", + " }", + ");", + "", + "pm.test(", + " 'Path segment is unique',", + " function() {", + " pm.expect(response.pathSegment).to.eql('this-is-an-example-news-1');", + " }", + ");", + "", + "pm.environment.set('duplicatedNewsEndpoint', response['@id']);", + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "name": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "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}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}{{newsEndpointPath}}", + "host": [ + "{{baseUrl}}{{newsEndpointPath}}" + ] + } + }, + "response": [] + }, + { + "name": "Delete news with the same title", + "event": [ + { + "listen": "test", + "script": { + "id": "e618d7f0-12a9-41e1-8028-9b003f18fa8e", + "exec": [ + "pm.test(", + " 'Status code is 200',", + " function () {", + " pm.response.to.have.status(200);", + " }", + ");", + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "DELETE", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "" + }, + "url": { + "raw": "{{baseUrl}}{{duplicatedNewsEndpoint}}", + "host": [ + "{{baseUrl}}{{duplicatedNewsEndpoint}}" + ] + } + }, + "response": [] + }, { "name": "Check list", "event": [ @@ -395,7 +504,15 @@ " function() {", " pm.expect(response.categories).to.eql([]);", " }", - ");" + ");", + "", + "pm.test(", + " 'Path segment is not changed',", + " function() {", + " pm.expect(response.pathSegment).to.eql('this-is-an-example-news');", + " }", + ");", + "" ], "type": "text/javascript" } @@ -451,7 +568,7 @@ ], "body": { "mode": "raw", - "raw": "{\n\t\"title\": \"This is even new title\",\n\t\"pid\": 3,\n\t\"pathSegment\":\"this_is_an_example_news\",\n\t\"datetime\": \"2019-12-27T16:58:23.063Z\",\n\t\"crdate\": \"2019-12-27T16:58:23.063Z\",\n\t\"categories\": [{{categoryUid}}],\n\t\"related\": [],\n\t\"relatedFrom\": []\n}" + "raw": "{\n\t\"title\": \"This is even new title\",\n\t\"datetime\": \"2019-12-27T16:58:23.063Z\",\n\t\"crdate\": \"2019-12-27T16:58:23.063Z\",\n\t\"categories\": [{{categoryUid}}],\n\t\"related\": [],\n\t\"relatedFrom\": []\n}" }, "url": { "raw": "{{baseUrl}}{{singleNewsEndpoint}}", @@ -498,7 +615,15 @@ " function() {", " pm.expect(response.categories[0].uid).to.eql(pm.environment.get('categoryUid'));", " }", - ");" + ");", + "", + "pm.test(", + " 'Path segment is generated from new title',", + " function() {", + " pm.expect(response.pathSegment).to.eql('this-is-even-new-title');", + " }", + ");", + "" ], "type": "text/javascript" } @@ -778,7 +903,7 @@ ], "body": { "mode": "raw", - "raw": "{\n\t\"title\": \"This is an example news with file reference added at once\",\n\t\"alternativeTitle\": \"Alt title\",\n\t\"pid\": 3,\n\t\"pathSegment\":\"this_is_an_example_news_with_file_reference\",\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\t\"falMedia\": [\n\t\t{\n\t\t\t\"uidLocal\": {{uploadedFieldUid}},\n\t\t\t\"showinpreview\": 1\n\t\t},\n\t\t{\n\t\t\t\"uidLocal\": {{uploadedFieldUid}},\n\t\t\t\"showinpreview\": 0\n\t\t}\n\t]\n}", + "raw": "{\n\t\"title\": \"This is an example news with file reference added at once\",\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\t\"falMedia\": [\n\t\t{\n\t\t\t\"uidLocal\": {{uploadedFieldUid}},\n\t\t\t\"showinpreview\": 1\n\t\t},\n\t\t{\n\t\t\t\"uidLocal\": {{uploadedFieldUid}},\n\t\t\t\"showinpreview\": 0\n\t\t}\n\t]\n}", "options": { "raw": { "language": "json" @@ -840,6 +965,13 @@ " pm.expect(response.falMedia[1].file.uid).to.equal(Number(pm.environment.get('uploadedFieldUid')));", " }", ");", + "", + "pm.test(", + " 'Path segment is generated from title',", + " function() {", + " pm.expect(response.pathSegment).to.eql('this-is-an-example-news-with-file-reference-added-at-once');", + " }", + ");", "" ], "type": "text/javascript"