diff --git a/Classes/Command/InvalidateExpiredResponseCacheCommand.php b/Classes/Command/InvalidateExpiredResponseCacheCommand.php new file mode 100644 index 0000000..fe0baae --- /dev/null +++ b/Classes/Command/InvalidateExpiredResponseCacheCommand.php @@ -0,0 +1,122 @@ +getTimeRestrictedTables() as $table) { + $registryKey = $this->getRegistryKeyForTable($table); + $lastExecutionTimestamp = (int)$this->registry->get(self::REGISTRY_NAMESPACE, $registryKey); + + if ($this->hasTimeBasedVisibilityChanged($table, $lastExecutionTimestamp, $executionTimestamp)) { + $output->writeln(sprintf('Flushing t3api response cache for table `%s`', $table)); + $this->cache->flushByTags([$table]); + } + + // Persisted only after the visibility check (and the flush it may have triggered) + // completed without throwing, so a DB error or crash does not silently lose this + // invalidation window - the next run will simply re-check it. + $this->registry->set(self::REGISTRY_NAMESPACE, $registryKey, $executionTimestamp); + } + + return Command::SUCCESS; + } + + /** + * Scans every TCA table with a starttime/endtime enablecolumn, not just cacheable + * resources: a cached response can embed related entities from other tables (e.g. an + * author nested in a book response), so a table never checked here could never trigger + * the flush that keeps those responses fresh. This broad scan stays safe because the + * flush itself is tag-scoped - `flushByTags([$table])` only ever hits entries that + * actually embedded a record of that table. + * + * @return string[] + */ + protected function getTimeRestrictedTables(): array + { + $tables = []; + foreach (array_keys($GLOBALS['TCA']) as $table) { + if ($this->getStartTimeAndEndTimeFields($table) !== []) { + $tables[] = $table; + } + } + + return $tables; + } + + protected function hasTimeBasedVisibilityChanged(string $table, int $lastExecutionTimestamp, int $executionTimestamp): bool + { + $enableFields = $this->getStartTimeAndEndTimeFields($table); + if ($enableFields === []) { + return false; + } + + $queryBuilder = $this->connectionPool->getQueryBuilderForTable($table); + $queryBuilder->getRestrictions()->removeAll(); + + $constraints = []; + foreach ($enableFields as $enableField) { + $constraints[] = $queryBuilder->expr()->and( + $queryBuilder->expr()->gt( + $enableField, + $queryBuilder->createNamedParameter($lastExecutionTimestamp, Connection::PARAM_INT) + ), + $queryBuilder->expr()->lte( + $enableField, + $queryBuilder->createNamedParameter($executionTimestamp, Connection::PARAM_INT) + ) + ); + } + + return (int)$queryBuilder + ->count('uid') + ->from($table) + ->where($queryBuilder->expr()->or(...$constraints)) + ->executeQuery() + ->fetchOne() > 0; + } + + /** + * @return string[] + */ + protected function getStartTimeAndEndTimeFields(string $table): array + { + return array_filter([ + 'starttime' => $GLOBALS['TCA'][$table]['ctrl']['enablecolumns']['starttime'] ?? null, + 'endtime' => $GLOBALS['TCA'][$table]['ctrl']['enablecolumns']['endtime'] ?? null, + ]); + } + + protected function getRegistryKeyForTable(string $table): string + { + return sprintf('%s_lastExecution', $table); + } +} diff --git a/Classes/Dispatcher/AbstractDispatcher.php b/Classes/Dispatcher/AbstractDispatcher.php index 814f86a..c0c1720 100644 --- a/Classes/Dispatcher/AbstractDispatcher.php +++ b/Classes/Dispatcher/AbstractDispatcher.php @@ -15,6 +15,7 @@ use SourceBroker\T3api\Processor\ProcessorInterface; use SourceBroker\T3api\Serializer\ContextBuilder\DeserializationContextBuilder; use SourceBroker\T3api\Serializer\ContextBuilder\SerializationContextBuilder; +use SourceBroker\T3api\Service\OperationResponseCache; use SourceBroker\T3api\Service\SerializerService; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Routing\Exception\MethodNotAllowedException as SymfonyMethodNotAllowedException; @@ -35,18 +36,22 @@ abstract class AbstractDispatcher protected DeserializationContextBuilder $deserializationContextBuilder; + protected OperationResponseCache $operationResponseCache; + public function __construct( SerializerService $serializerService, ApiResourceRepository $apiResourceRepository, SerializationContextBuilder $serializationContextBuilder, DeserializationContextBuilder $deserializationContextBuilder, - EventDispatcherInterface $eventDispatcherInterface + EventDispatcherInterface $eventDispatcherInterface, + OperationResponseCache $operationResponseCache ) { $this->serializerService = $serializerService; $this->apiResourceRepository = $apiResourceRepository; $this->serializationContextBuilder = $serializationContextBuilder; $this->deserializationContextBuilder = $deserializationContextBuilder; $this->eventDispatcher = $eventDispatcherInterface; + $this->operationResponseCache = $operationResponseCache; } /** @@ -62,12 +67,18 @@ public function processOperationByRequest( try { $matchedRoute = (new UrlMatcher($apiResource->getRoutes(), $requestContext)) ->matchRequest($request); + $operation = $apiResource->getOperationByRouteName($matchedRoute['_route']); + $result = null; - return $this->processOperation( - $apiResource->getOperationByRouteName($matchedRoute['_route']), + return $this->operationResponseCache->resolve( + $operation, $matchedRoute, $request, - $response + function () use ($operation, $matchedRoute, $request, &$response, &$result) { + return $this->processOperation($operation, $matchedRoute, $request, $response, $result); + }, + $response, + $result ); } catch (SymfonyResourceNotFoundException $resourceNotFoundException) { // do not stop - continue to find correct route @@ -80,17 +91,27 @@ public function processOperationByRequest( } /** + * `$result` is an internal, protected-only out-parameter: it receives the operation handler's + * un-serialized result (post `AfterProcessOperationEvent`) so `processOperationByRequest()`'s + * closure can hand it to `OperationResponseCache::resolve()`, which needs it to evaluate the + * `object` expression variable in `cache.memberTagExpressions` and + * `cacheInvalidation.tagExpressions` - see `OperationResponseCache::resolve()`. It is not part + * of this method's public contract; callers that do not need it simply omit it, same as + * `$response`. + * * @param OperationInterface $operation * @param array $route * @param Request $request * @param ResponseInterface|null $response + * @param mixed $result * @return string */ protected function processOperation( OperationInterface $operation, array $route, Request $request, - ?ResponseInterface &$response = null + ?ResponseInterface &$response = null, + mixed &$result = null ): string { $handlers = $this->getHandlersSupportingOperation($operation, $request); diff --git a/Classes/Dispatcher/Bootstrap.php b/Classes/Dispatcher/Bootstrap.php index f1f6086..ba40087 100644 --- a/Classes/Dispatcher/Bootstrap.php +++ b/Classes/Dispatcher/Bootstrap.php @@ -11,6 +11,7 @@ use SourceBroker\T3api\Exception\ExceptionInterface; use SourceBroker\T3api\Serializer\ContextBuilder\DeserializationContextBuilder; use SourceBroker\T3api\Serializer\ContextBuilder\SerializationContextBuilder; +use SourceBroker\T3api\Service\OperationResponseCache; use SourceBroker\T3api\Service\RouteService; use SourceBroker\T3api\Service\SerializerService; use Symfony\Bridge\PsrHttpMessage\Factory\HttpFoundationFactory; @@ -34,7 +35,8 @@ public function __construct( ApiResourceRepository $apiResourceRepository, SerializationContextBuilder $serializationContextBuilder, DeserializationContextBuilder $deserializationContextBuilder, - EventDispatcherInterface $eventDispatcherInterface + EventDispatcherInterface $eventDispatcherInterface, + OperationResponseCache $operationResponseCache ) { parent::__construct( $serializerService, @@ -42,6 +44,7 @@ public function __construct( $serializationContextBuilder, $deserializationContextBuilder, $eventDispatcherInterface, + $operationResponseCache, ); $this->response = new Response('php://temp', 200, ['Content-Type' => 'application/ld+json']); $this->httpFoundationFactory = GeneralUtility::makeInstance(HttpFoundationFactory::class); diff --git a/Classes/Domain/Model/AbstractOperation.php b/Classes/Domain/Model/AbstractOperation.php index b6a80c4..3789d3c 100644 --- a/Classes/Domain/Model/AbstractOperation.php +++ b/Classes/Domain/Model/AbstractOperation.php @@ -32,6 +32,10 @@ abstract class AbstractOperation implements OperationInterface protected UploadSettings $uploadSettings; + protected ResponseCacheSettings $responseCacheSettings; + + protected CacheInvalidationSettings $cacheInvalidationSettings; + public function __construct(string $key, ApiResource $apiResource, array $params) { $this->key = $key; @@ -63,6 +67,14 @@ public function __construct(string $key, ApiResource $apiResource, array $params $params['attributes']['upload'] ?? [], $apiResource->getUploadSettings() ); + $this->responseCacheSettings = ResponseCacheSettings::create( + $params['attributes']['cache'] ?? [], + $apiResource->getResponseCacheSettings() + ); + $this->cacheInvalidationSettings = CacheInvalidationSettings::create( + $params['attributes']['cacheInvalidation'] ?? [], + $apiResource->getCacheInvalidationSettings() + ); } public function getKey(): string @@ -144,4 +156,14 @@ public function getUploadSettings(): UploadSettings { return $this->uploadSettings; } + + public function getResponseCacheSettings(): ResponseCacheSettings + { + return $this->responseCacheSettings; + } + + public function getCacheInvalidationSettings(): CacheInvalidationSettings + { + return $this->cacheInvalidationSettings; + } } diff --git a/Classes/Domain/Model/ApiResource.php b/Classes/Domain/Model/ApiResource.php index 0941591..ba06693 100644 --- a/Classes/Domain/Model/ApiResource.php +++ b/Classes/Domain/Model/ApiResource.php @@ -34,6 +34,10 @@ class ApiResource protected UploadSettings $uploadSettings; + protected ResponseCacheSettings $responseCacheSettings; + + protected CacheInvalidationSettings $cacheInvalidationSettings; + public function __construct(string $entity, ApiResourceAnnotation $apiResourceAnnotation) { $this->entity = $entity; @@ -43,6 +47,8 @@ public function __construct(string $entity, ApiResourceAnnotation $apiResourceAn $this->pagination = Pagination::create($attributes); $this->persistenceSettings = PersistenceSettings::create($attributes['persistence'] ?? []); $this->uploadSettings = UploadSettings::create($attributes['upload'] ?? []); + $this->responseCacheSettings = ResponseCacheSettings::create($attributes['cache'] ?? []); + $this->cacheInvalidationSettings = CacheInvalidationSettings::create($attributes['cacheInvalidation'] ?? []); foreach ($apiResourceAnnotation->getItemOperations() as $operationKey => $operationData) { $this->itemOperations[] = new ItemOperation($operationKey, $this, $operationData); @@ -151,4 +157,14 @@ public function getUploadSettings(): UploadSettings { return $this->uploadSettings; } + + public function getResponseCacheSettings(): ResponseCacheSettings + { + return $this->responseCacheSettings; + } + + public function getCacheInvalidationSettings(): CacheInvalidationSettings + { + return $this->cacheInvalidationSettings; + } } diff --git a/Classes/Domain/Model/CacheInvalidationSettings.php b/Classes/Domain/Model/CacheInvalidationSettings.php new file mode 100644 index 0000000..3387663 --- /dev/null +++ b/Classes/Domain/Model/CacheInvalidationSettings.php @@ -0,0 +1,80 @@ +explicitlyConfigured = $attributes !== []; + $cacheInvalidationSettings->tags = $attributes['tags'] ?? $cacheInvalidationSettings->tags; + $cacheInvalidationSettings->tagExpressions = $attributes['tagExpressions'] + ?? $cacheInvalidationSettings->tagExpressions; + + return $cacheInvalidationSettings; + } + + /** + * True when THIS settings object's own `attributes` block was non-empty - a resource-level + * block, or a per-operation block declared directly on the operation. False when the + * settings were produced purely by cascading a base's values forward (an empty per-operation + * `attributes` block inheriting a resource-level block, or the empty default). Used to tell + * apart "this operation itself configured `cacheInvalidation`" from "this operation merely + * inherited it" - see {@see \SourceBroker\T3api\Service\ApiResourceConfigurationValidator}. + */ + public function wasExplicitlyConfigured(): bool + { + return $this->explicitlyConfigured; + } + + /** + * Literal tags flushed after a non-GET operation executes successfully, regardless of whether + * the operation itself has a cacheable response. Plain strings only - for a tag whose value + * depends on the matched route parameters (or anything else dynamic), use `tagExpressions` + * instead. + * + * @return string[] + */ + public function getTags(): array + { + return $this->tags; + } + + /** + * Symfony expressions whose non-empty string results are each flushed as an extra tag + * alongside `tags`, after a non-GET operation executes successfully - lets a write flush + * anything visible to the expression, e.g. the matched route parameters (via `route`, see + * :ref:`response-cache-conditions`) or exactly the current user's entries (via a + * project-provided `user` variable). Evaluated with the same resolver and variable set as + * `readCondition`/`identifierExpressions`. An empty string result is a valid "no tag from this + * expression" result, the idiom for conditional tagging. + * + * @return string[] + */ + public function getTagExpressions(): array + { + return $this->tagExpressions; + } +} diff --git a/Classes/Domain/Model/OperationInterface.php b/Classes/Domain/Model/OperationInterface.php index 549387d..2a6617e 100644 --- a/Classes/Domain/Model/OperationInterface.php +++ b/Classes/Domain/Model/OperationInterface.php @@ -28,6 +28,10 @@ public function getSecurityPostDenormalize(): string; public function getPersistenceSettings(): PersistenceSettings; + public function getResponseCacheSettings(): ResponseCacheSettings; + + public function getCacheInvalidationSettings(): CacheInvalidationSettings; + public function isMethodGet(): bool; public function isMethodPut(): bool; diff --git a/Classes/Domain/Model/Pagination.php b/Classes/Domain/Model/Pagination.php index 1e4399c..45bdde2 100644 --- a/Classes/Domain/Model/Pagination.php +++ b/Classes/Domain/Model/Pagination.php @@ -28,6 +28,11 @@ class Pagination extends AbstractOperationResourceSettings protected array $parameters = []; + /** + * @param array $attributes + * @param Pagination|null $pagination + * @return Pagination + */ public static function create( array $attributes = [], ?AbstractOperationResourceSettings $pagination = null diff --git a/Classes/Domain/Model/PersistenceSettings.php b/Classes/Domain/Model/PersistenceSettings.php index 0711965..6071d12 100644 --- a/Classes/Domain/Model/PersistenceSettings.php +++ b/Classes/Domain/Model/PersistenceSettings.php @@ -15,6 +15,10 @@ class PersistenceSettings extends AbstractOperationResourceSettings protected int $recursionLevel = 0; + /** + * @param PersistenceSettings|null $persistenceSettings + * @return PersistenceSettings + */ public static function create( array $attributes = [], ?AbstractOperationResourceSettings $persistenceSettings = null diff --git a/Classes/Domain/Model/ResponseCacheSettings.php b/Classes/Domain/Model/ResponseCacheSettings.php new file mode 100644 index 0000000..230fa6c --- /dev/null +++ b/Classes/Domain/Model/ResponseCacheSettings.php @@ -0,0 +1,182 @@ +explicitlyConfigured = $attributes !== []; + $responseCacheSettings->enabled = self::resolveEnabled($attributes, $responseCacheSettings->enabled); + $responseCacheSettings->lifetime = isset($attributes['lifetime']) + ? (int)$attributes['lifetime'] : $responseCacheSettings->lifetime; + $responseCacheSettings->readCondition = $attributes['readCondition'] ?? $responseCacheSettings->readCondition; + $responseCacheSettings->writeCondition = $attributes['writeCondition'] ?? $responseCacheSettings->writeCondition; + $responseCacheSettings->identifierExpressions = $attributes['identifierExpressions'] + ?? $responseCacheSettings->identifierExpressions; + $responseCacheSettings->tags = $attributes['tags'] ?? $responseCacheSettings->tags; + $responseCacheSettings->tagExpressions = $attributes['tagExpressions'] + ?? $responseCacheSettings->tagExpressions; + $responseCacheSettings->memberTagExpressions = $attributes['memberTagExpressions'] + ?? $responseCacheSettings->memberTagExpressions; + + return $responseCacheSettings; + } + + /** + * An explicit `enabled` key always wins. Otherwise a non-empty attributes block implies + * caching is enabled - this lets a resource (or operation) turn caching on simply by + * declaring any cache option, without repeating `"enabled"=true`. An empty block inherits + * whatever the base (resource, or the disabled default) already decided. + */ + private static function resolveEnabled(array $attributes, bool $baseEnabled): bool + { + if (isset($attributes['enabled'])) { + return ParameterUtility::toBoolean($attributes['enabled']); + } + + if ($attributes !== []) { + return true; + } + + return $baseEnabled; + } + + public function isEnabled(): bool + { + return $this->enabled; + } + + /** + * True when THIS settings object's own `attributes` block was non-empty - a resource-level + * block, or a per-operation block declared directly on the operation. False when the + * settings were produced purely by cascading a base's values forward (an empty per-operation + * `attributes` block inheriting a resource-level block, or the disabled default), even though + * `isEnabled()` may still report `true` in that case. Used to tell apart "this operation + * itself configured `cache`" from "this operation merely inherited `cache`" - see + * {@see \SourceBroker\T3api\Service\ApiResourceConfigurationValidator}. + */ + public function wasExplicitlyConfigured(): bool + { + return $this->explicitlyConfigured; + } + + public function getLifetime(): int + { + return $this->lifetime; + } + + public function getReadCondition(): string + { + return $this->readCondition; + } + + public function getWriteCondition(): string + { + return $this->writeCondition; + } + + /** + * Symfony expressions whose string results are each appended to the cache entry identifier - + * lets the cache vary by something outside path/query, e.g. a request header (via `request`) + * or a TYPO3 Context aspect (via `context.getPropertyFromAspect(...)`). An empty array (the + * default) leaves the identifier unaffected. + * + * @return string[] + */ + public function getIdentifierExpressions(): array + { + return $this->identifierExpressions; + } + + /** + * Extra literal tags added to a stored entry, on top of the automatic content tags and the + * resource table seed. Plain strings only - for a tag whose value depends on the matched route + * parameters (or anything else dynamic), use `tagExpressions` instead. + * + * @return string[] + */ + public function getTags(): array + { + return $this->tags; + } + + /** + * Symfony expressions whose non-empty string results are each added as an extra tag to a + * stored entry, on top of `tags` and the automatic content tags - lets a stored entry be + * tagged by anything visible to the expression, e.g. the matched route parameters (via + * `route`, see :ref:`response-cache-conditions`) or the current user (via a project-provided + * `user` variable). Evaluated with the same resolver and variable set as + * `readCondition`/`identifierExpressions`. An empty string result is a valid "no tag from this + * expression" result, the idiom for conditional tagging. + * + * @return string[] + */ + public function getTagExpressions(): array + { + return $this->tagExpressions; + } + + /** + * Symfony expressions evaluated once per top-level result entity instead of once per response + * - an item GET evaluates each expression once, against the single result entity; a collection + * GET evaluates each expression once per collection member, the resulting tags unioned and + * deduplicated across all members; an empty collection produces zero evaluations. Each + * evaluation sees the same variable set as `tagExpressions` (see + * :ref:`response-cache-tag-expressions`) plus `object`, bound to that one entity - the one + * variable `tagExpressions` itself never has access to, since it is evaluated once for the + * whole response, before any entity is known to exist. Non-empty string results are added as + * extra tags to a stored entry, on top of `tags` and `tagExpressions`; an empty string result + * is the same "no tag from this expression" idiom used elsewhere. + * + * @return string[] + */ + public function getMemberTagExpressions(): array + { + return $this->memberTagExpressions; + } +} diff --git a/Classes/Event/AbstractRecordEvent.php b/Classes/Event/AbstractRecordEvent.php new file mode 100644 index 0000000..886e896 --- /dev/null +++ b/Classes/Event/AbstractRecordEvent.php @@ -0,0 +1,23 @@ +uid; + } + + public function getTable(): string + { + return $this->table; + } +} diff --git a/Classes/Event/RecordDeletedEvent.php b/Classes/Event/RecordDeletedEvent.php new file mode 100644 index 0000000..ff84500 --- /dev/null +++ b/Classes/Event/RecordDeletedEvent.php @@ -0,0 +1,7 @@ +` tag for these. False for a plain + * field update to an already-persisted record, which only needs its own `_` + * tag flushed, since collections that already contain the record carry that tag too. + */ + public function changesCollectionMembership(): bool + { + return $this->changesCollectionMembership; + } +} diff --git a/Classes/EventListener/FlushCacheAfterSaveListener.php b/Classes/EventListener/FlushCacheAfterSaveListener.php new file mode 100644 index 0000000..0a9cf24 --- /dev/null +++ b/Classes/EventListener/FlushCacheAfterSaveListener.php @@ -0,0 +1,51 @@ +cache->flushByTags($this->resolveUpdateTags($event)); + } + + /** + * A deleted record's own `
_` tag is implied dead along with it, and the + * `
--collection` tag covers every collection that contained the record - the same + * two-tag shape a membership-changing update flushes, see `resolveUpdateTags()`. + */ + public function afterDeleted(RecordDeletedEvent $event): void + { + $this->cache->flushByTags([$event->getTable() . '--collection', $event->getTable() . '_' . $event->getUid()]); + } + + /** + * A plain update only needs the record's own `
_` tag flushed - every cached + * collection that already contains the record was tagged with that same uid when it was + * serialized, so it still refreshes correctly. A membership-changing update flushes both the + * `
--collection` tag - covering other collections whose membership may now include or + * exclude the record - and the record's own `
_` tag, since the record's own + * previously-cached item/collection-member entries may now be stale too (e.g. a newly-hidden + * record's cached item response must stop being served). Including the uid tag is always safe + * even when nothing was ever cached under it yet, so create/move/undelete need no special case. + * + * @return string[] + */ + private function resolveUpdateTags(RecordUpdatedEvent $event): array + { + $uidTag = $event->getTable() . '_' . $event->getUid(); + if (!$event->changesCollectionMembership()) { + return [$uidTag]; + } + + return [$event->getTable() . '--collection', $uidTag]; + } +} diff --git a/Classes/EventListener/PersistenceEventListener.php b/Classes/EventListener/PersistenceEventListener.php new file mode 100644 index 0000000..57050d9 --- /dev/null +++ b/Classes/EventListener/PersistenceEventListener.php @@ -0,0 +1,58 @@ +dispatchRecordUpdated($event->getObject(), changesCollectionMembership: true); + } + + public function entityUpdated(EntityUpdatedInPersistenceEvent $event): void + { + $this->dispatchRecordUpdated($event->getObject(), changesCollectionMembership: false); + } + + public function entityRemoved(EntityRemovedFromPersistenceEvent $event): void + { + $object = $event->getObject(); + $this->eventDispatcher->dispatch( + new RecordDeletedEvent((int)$object->getUid(), $this->getTableName($object)) + ); + } + + private function dispatchRecordUpdated(DomainObjectInterface $object, bool $changesCollectionMembership): void + { + $this->eventDispatcher->dispatch( + new RecordUpdatedEvent((int)$object->getUid(), $this->getTableName($object), $changesCollectionMembership) + ); + } + + private function getTableName(object $object): string + { + return $this->dataMapFactory->buildDataMap(get_class($object))->getTableName(); + } +} diff --git a/Classes/Exception/AbstractException.php b/Classes/Exception/AbstractException.php index e0b77f2..5d9cb95 100644 --- a/Classes/Exception/AbstractException.php +++ b/Classes/Exception/AbstractException.php @@ -20,9 +20,19 @@ abstract class AbstractException extends \Exception implements ExceptionInterfac */ protected string $title; + /** + * Falls back to the raw, untranslated `$key` when the translation layer itself fails - an + * exception reporting a real failure (e.g. access denied) must still construct successfully + * even if TYPO3's language service is unavailable or misconfigured, rather than obscuring the + * original problem behind an unrelated translation error. + */ protected static function translate(string $key, ?array $arguments = null): ?string { - return LocalizationUtility::translate($key, 't3api', $arguments); + try { + return LocalizationUtility::translate($key, 't3api', $arguments) ?? $key; + } catch (\Throwable) { + return $key; + } } public static function getOpenApiResponse(): OpenApiResponse diff --git a/Classes/ExpressionLanguage/T3apiCoreProvider.php b/Classes/ExpressionLanguage/T3apiCoreProvider.php index 85af5f1..bc0bbd5 100644 --- a/Classes/ExpressionLanguage/T3apiCoreProvider.php +++ b/Classes/ExpressionLanguage/T3apiCoreProvider.php @@ -4,8 +4,15 @@ namespace SourceBroker\T3api\ExpressionLanguage; +use TYPO3\CMS\Core\Context\Context; use TYPO3\CMS\Core\ExpressionLanguage\AbstractProvider; +use TYPO3\CMS\Core\Utility\GeneralUtility; +/** + * Registers t3api's own expression functions and variables for the `t3api` expression-language + * context, shared by `security`/`security_post_denormalize` and the response cache + * `readCondition`/`writeCondition`/`identifierExpressions` expressions. + */ class T3apiCoreProvider extends AbstractProvider { public function __construct() @@ -13,5 +20,14 @@ public function __construct() $this->expressionLanguageProviders = [ T3apiCoreFunctionsProvider::class, ]; + + // Exposes the TYPO3 `Context` object as `context`, mirroring the variable TYPO3 core + // itself passes into TypoScript conditions + // (`IncludeTreeConditionMatcherVisitor::initializeExpressionMatcherWithVariables()`), so + // an expression can read any Context aspect directly, e.g. + // `context.getPropertyFromAspect('frontend.user', 'groupIds', '')`. + $this->expressionLanguageVariables = [ + 'context' => GeneralUtility::makeInstance(Context::class), + ]; } } diff --git a/Classes/Factory/ApiResourceFactory.php b/Classes/Factory/ApiResourceFactory.php index 8de506f..d8523cd 100644 --- a/Classes/Factory/ApiResourceFactory.php +++ b/Classes/Factory/ApiResourceFactory.php @@ -9,25 +9,23 @@ use SourceBroker\T3api\Annotation\ApiResource as ApiResourceAnnotation; use SourceBroker\T3api\Domain\Model\ApiFilter; use SourceBroker\T3api\Domain\Model\ApiResource; +use SourceBroker\T3api\Service\ApiResourceConfigurationValidator; class ApiResourceFactory { protected AnnotationReader $annotationReader; - public function __construct() - { + public function __construct( + protected readonly ApiResourceConfigurationValidator $apiResourceConfigurationValidator + ) { $this->annotationReader = new AnnotationReader(); } public function createApiResourceFromFqcn(string $fqcn): ?ApiResource { - /** @var ApiResourceAnnotation $apiResourceAnnotation */ - $apiResourceAnnotation = $this->annotationReader->getClassAnnotation( - new \ReflectionClass($fqcn), - ApiResourceAnnotation::class - ); + $apiResourceAnnotation = $this->getApiResourceAnnotation($fqcn); - if (!$apiResourceAnnotation instanceof ApiResourceAnnotation) { + if ($apiResourceAnnotation === null) { return null; } @@ -35,9 +33,21 @@ public function createApiResourceFromFqcn(string $fqcn): ?ApiResource $this->addFiltersToApiResource($apiResource); + $this->apiResourceConfigurationValidator->validate($apiResource); + return $apiResource; } + protected function getApiResourceAnnotation(string $fqcn): ?ApiResourceAnnotation + { + $apiResourceAnnotation = $this->annotationReader->getClassAnnotation( + new \ReflectionClass($fqcn), + ApiResourceAnnotation::class + ); + + return $apiResourceAnnotation instanceof ApiResourceAnnotation ? $apiResourceAnnotation : null; + } + protected function addFiltersToApiResource(ApiResource $apiResource): void { $filterAnnotations = array_filter( diff --git a/Classes/Hook/ResponseCacheDataHandlerHook.php b/Classes/Hook/ResponseCacheDataHandlerHook.php new file mode 100644 index 0000000..4b5a2fa --- /dev/null +++ b/Classes/Hook/ResponseCacheDataHandlerHook.php @@ -0,0 +1,106 @@ +substNEWwithIDs[$uid])) { + return; + } + $uid = $dataHandler->substNEWwithIDs[$uid]; + } + + $this->eventDispatcher->dispatch( + new RecordUpdatedEvent((int)$uid, $table, $this->affectsCollectionMembership($status, $table, $fields)) + ); + } + + /** + * True for a new record (as before), and additionally for an update that touches a field + * capable of moving the record in or out of a collection's result set: an enable-column + * (e.g. `hidden`, `starttime`, `endtime`, `fe_group`), `pid`, or the table's manual sorting + * field. A plain update to any other field cannot change collection membership. + * + * @param array $fields + */ + private function affectsCollectionMembership(string $status, string $table, array $fields): bool + { + if ($status === 'new') { + return true; + } + + return $this->touchesVisibilityOrPlacementField($table, $fields); + } + + /** + * @param array $fields + */ + private function touchesVisibilityOrPlacementField(string $table, array $fields): bool + { + foreach ($this->getVisibilityOrPlacementFieldNames($table) as $fieldName) { + if (array_key_exists($fieldName, $fields)) { + return true; + } + } + + return false; + } + + /** + * @return string[] + */ + private function getVisibilityOrPlacementFieldNames(string $table): array + { + $ctrl = $GLOBALS['TCA'][$table]['ctrl'] ?? []; + + $fieldNames = array_values($ctrl['enablecolumns'] ?? []); + $fieldNames[] = 'pid'; + + if (!empty($ctrl['sortby'])) { + $fieldNames[] = $ctrl['sortby']; + } + + return $fieldNames; + } + + public function processCmdmap_preProcess( + string $command, + string $table, + int|string $uid, + mixed $value, + DataHandler $dataHandler + ): void { + if ($command === 'delete') { + $this->eventDispatcher->dispatch(new RecordDeletedEvent((int)$uid, $table)); + + return; + } + + // A record moved or undeleted into a queried storage was in no cached entry before, so no + // uid tag would match it - without this, only a plain create (handled via + // processDatamap_afterDatabaseOperations()) would flush the affected collection responses. + if ($command === 'move' || $command === 'undelete') { + $this->eventDispatcher->dispatch( + new RecordUpdatedEvent((int)$uid, $table, changesCollectionMembership: true) + ); + } + } +} diff --git a/Classes/Security/AbstractAccessChecker.php b/Classes/Security/AbstractAccessChecker.php index ad19d03..d6ff084 100644 --- a/Classes/Security/AbstractAccessChecker.php +++ b/Classes/Security/AbstractAccessChecker.php @@ -6,10 +6,8 @@ use Psr\EventDispatcher\EventDispatcherInterface; use SourceBroker\T3api\ExpressionLanguage\Resolver; +use SourceBroker\T3api\Service\ExpressionLanguageService; use TYPO3\CMS\Core\Context\Context; -use TYPO3\CMS\Core\Context\Exception\AspectNotFoundException; -use TYPO3\CMS\Core\Context\Exception\AspectPropertyNotFoundException; -use TYPO3\CMS\Core\Context\UserAspect; use TYPO3\CMS\Core\Utility\GeneralUtility; class AbstractAccessChecker @@ -24,38 +22,9 @@ protected function getExpressionLanguageResolver(array $additionalExpressionLang if ($expressionLanguageResolver === null) { $context = GeneralUtility::makeInstance(Context::class); - try { - if ($context->hasAspect('backend.user')) { - /** @var UserAspect $backendUserAspect */ - $backendUserAspect = $context->getAspect('backend.user'); - $backend = new \stdClass(); - $backend->user = new \stdClass(); - $backend->user->isAdmin = $backendUserAspect->get('isAdmin'); - $backend->user->isLoggedIn = $backendUserAspect->get('isLoggedIn'); - $backend->user->userId = $backendUserAspect->get('id'); - $backend->user->userGroupList = implode(',', $backendUserAspect->get('groupIds')); - } - - if ($context->hasAspect('frontend.user')) { - /** @var UserAspect $frontendUserAspect */ - $frontendUserAspect = $context->getAspect('frontend.user'); - $frontend = new \stdClass(); - $frontend->user = new \stdClass(); - $frontend->user->isLoggedIn = $frontendUserAspect->get('isLoggedIn'); - $frontend->user->userId = $frontendUserAspect->get('id'); - $frontend->user->userGroupList = implode(',', $frontendUserAspect->get('groupIds')); - } - } catch (AspectNotFoundException $e) { - // this catch exists only to avoid IDE complaints - such error can not be thrown since `getAspect` - // usages are wrapped with `hasAspect` conditions - } catch (AspectPropertyNotFoundException $e) { - } $variables = array_merge( - [ - 'backend' => $backend ?? null, - 'frontend' => $frontend ?? null, - ], + ExpressionLanguageService::getUserVariables($context), $additionalExpressionLanguageVariables ); diff --git a/Classes/Serializer/Subscriber/CacheTagSubscriber.php b/Classes/Serializer/Subscriber/CacheTagSubscriber.php new file mode 100644 index 0000000..20c5c12 --- /dev/null +++ b/Classes/Serializer/Subscriber/CacheTagSubscriber.php @@ -0,0 +1,54 @@ +> + */ + public static function getSubscribedEvents(): array + { + return [ + [ + 'event' => Events::POST_SERIALIZE, + 'method' => 'onPostSerialize', + ], + ]; + } + + public function onPostSerialize(ObjectEvent $event): void + { + if (!$this->cacheTagCollector->isCollecting()) { + return; + } + + $object = $event->getObject(); + if (!$object instanceof AbstractDomainObject) { + return; + } + + $tableName = $this->dataMapper->getDataMap($event->getType()['name'])->getTableName(); + if ($object->getUid() === null) { + $this->cacheTagCollector->addTags($tableName); + + return; + } + + $this->cacheTagCollector->addTags($tableName, $tableName . '_' . $object->getUid()); + } +} diff --git a/Classes/Service/ApiResourceConfigurationValidator.php b/Classes/Service/ApiResourceConfigurationValidator.php new file mode 100644 index 0000000..4ca721f --- /dev/null +++ b/Classes/Service/ApiResourceConfigurationValidator.php @@ -0,0 +1,81 @@ +getOperations() as $operation) { + $this->assertResponseCacheNotExplicitlyConfiguredOnNonGetOperation($apiResource, $operation); + $this->assertCacheInvalidationNotExplicitlyConfiguredOnGetOperation($apiResource, $operation); + } + } + + /** + * `cache` configures response caching, which only ever applies to a GET operation - declaring + * it explicitly on a non-GET operation is always a configuration mistake, since the declared + * block would cascade but never actually be read at runtime. Only an EXPLICIT per-operation + * block is rejected - a block inherited from the resource level is a legitimate cascade (e.g. + * a resource-level `cache` block reaching a non-GET operation, which simply ignores it at + * runtime) and must never throw. + */ + private function assertResponseCacheNotExplicitlyConfiguredOnNonGetOperation( + ApiResource $apiResource, + OperationInterface $operation + ): void { + if ($operation->isMethodGet() || !$operation->getResponseCacheSettings()->wasExplicitlyConfigured()) { + return; + } + + throw new \InvalidArgumentException( + sprintf( + 'Operation `%s` of entity `%s` is not a GET operation but declares an explicit ' . + '`attributes.cache` block - `cache` configures response caching, which only ever applies ' . + 'to a GET operation. Use `cacheInvalidation` instead to flush tags after this operation ' . + 'writes successfully.', + $operation->getKey(), + $apiResource->getEntity() + ), + 1783923801687 + ); + } + + /** + * `cacheInvalidation` flushes tags after a non-GET operation writes successfully and never + * applies to GET - declaring it explicitly on a GET operation is always a configuration + * mistake. Only an EXPLICIT per-operation block is rejected - a block inherited from the + * resource level (e.g. a resource-level `cacheInvalidation` block reaching a GET operation, + * which simply ignores it at runtime) is a legitimate cascade and must never throw. + */ + private function assertCacheInvalidationNotExplicitlyConfiguredOnGetOperation( + ApiResource $apiResource, + OperationInterface $operation + ): void { + if (!$operation->isMethodGet() || !$operation->getCacheInvalidationSettings()->wasExplicitlyConfigured()) { + return; + } + + throw new \InvalidArgumentException( + sprintf( + 'Operation `%s` of entity `%s` is a GET operation but declares an explicit ' . + '`attributes.cacheInvalidation` block - `cacheInvalidation` flushes tags after a non-GET ' . + 'operation writes successfully, and never applies to GET. Use `cache` instead to configure ' . + 'response caching for this operation.', + $operation->getKey(), + $apiResource->getEntity() + ), + 1783923801688 + ); + } +} diff --git a/Classes/Service/CacheTagCollector.php b/Classes/Service/CacheTagCollector.php new file mode 100644 index 0000000..aef90fe --- /dev/null +++ b/Classes/Service/CacheTagCollector.php @@ -0,0 +1,53 @@ + + */ + protected array $tags = []; + + public function start(): void + { + $this->collecting = true; + $this->tags = []; + } + + public function isCollecting(): bool + { + return $this->collecting; + } + + public function addTags(string ...$tags): void + { + if (!$this->collecting) { + return; + } + + foreach ($tags as $tag) { + $this->tags[$tag] = true; + } + } + + /** + * Ends collecting and returns the unique tags gathered since start(). + * + * @return string[] + */ + public function stop(): array + { + $this->collecting = false; + $tags = array_keys($this->tags); + $this->tags = []; + + return $tags; + } +} diff --git a/Classes/Service/ExpressionLanguageService.php b/Classes/Service/ExpressionLanguageService.php index 2a7228b..45165fe 100644 --- a/Classes/Service/ExpressionLanguageService.php +++ b/Classes/Service/ExpressionLanguageService.php @@ -6,6 +6,9 @@ use SourceBroker\T3api\ExpressionLanguage\Resolver; use Symfony\Component\ExpressionLanguage\ExpressionLanguage; +use TYPO3\CMS\Core\Context\Context; +use TYPO3\CMS\Core\Context\Exception\AspectPropertyNotFoundException; +use TYPO3\CMS\Core\Context\UserAspect; use TYPO3\CMS\Core\Utility\GeneralUtility; class ExpressionLanguageService @@ -14,4 +17,50 @@ public static function getT3apiExpressionLanguage(): ExpressionLanguage { return GeneralUtility::makeInstance(Resolver::class, 't3api', [])->getExpressionLanguage(); } + + /** + * Builds the `backend`/`frontend` expression variables shared by the `security` conditions + * (`AbstractAccessChecker`) and the response cache conditions (`ResponseCacheService`), from + * the `backend.user`/`frontend.user` Context aspects. + * + * Should be kept as close as possible to \TYPO3\CMS\Core\TypoScript\IncludeTree\Visitor\IncludeTreeConditionMatcherVisitor::initializeExpressionMatcherWithVariables + * + * @return array{backend: ?\stdClass, frontend: ?\stdClass} + */ + public static function getUserVariables(Context $context): array + { + $backend = null; + $frontend = null; + + try { + if ($context->hasAspect('backend.user')) { + /** @var UserAspect $backendUserAspect */ + $backendUserAspect = $context->getAspect('backend.user'); + $backend = new \stdClass(); + $backend->user = new \stdClass(); + $backend->user->isAdmin = $backendUserAspect->get('isAdmin'); + $backend->user->isLoggedIn = $backendUserAspect->get('isLoggedIn'); + $backend->user->userId = $backendUserAspect->get('id'); + $backend->user->userGroupList = implode(',', $backendUserAspect->get('groupIds')); + $backend->user->userGroupIds = $backendUserAspect->get('groupIds'); + } + + if ($context->hasAspect('frontend.user')) { + /** @var UserAspect $frontendUserAspect */ + $frontendUserAspect = $context->getAspect('frontend.user'); + $frontend = new \stdClass(); + $frontend->user = new \stdClass(); + $frontend->user->isLoggedIn = $frontendUserAspect->get('isLoggedIn'); + $frontend->user->userId = $frontendUserAspect->get('id'); + $frontend->user->userGroupList = implode(',', $frontendUserAspect->get('groupIds')); + $frontend->user->userGroupIds = $frontendUserAspect->get('groupIds'); + } + } catch (AspectPropertyNotFoundException $e) { + } + + return [ + 'backend' => $backend, + 'frontend' => $frontend, + ]; + } } diff --git a/Classes/Service/OperationResponseCache.php b/Classes/Service/OperationResponseCache.php new file mode 100644 index 0000000..3735209 --- /dev/null +++ b/Classes/Service/OperationResponseCache.php @@ -0,0 +1,474 @@ +responseCacheService->buildEntryIdentifier($operation, $route, $request); + if ($cacheEntryIdentifier === null) { + return $this->processAndInvalidateCache($operation, $route, $request, $processOperation, $response, $result); + } + + $accessBypassOutput = $this->enforceOperationSecurity($operation, $route, $request, $processOperation, $response); + if ($accessBypassOutput !== null) { + return $accessBypassOutput; + } + + if ($this->responseCacheService->isReadAllowed($operation, $route, $request)) { + $cachedOutput = $this->responseCacheService->get($cacheEntryIdentifier); + if ($cachedOutput !== null) { + $this->responseCacheDebugHeaders->hit($response, $cacheEntryIdentifier); + + return $cachedOutput; + } + + $this->responseCacheDebugHeaders->lookupOutcome($response, 'miss'); + } else { + $this->responseCacheDebugHeaders->lookupOutcome($response, 'bypass'); + } + + return $this->processAndStore( + $operation, + $route, + $request, + $cacheEntryIdentifier, + $processOperation, + $response, + $result + ); + } + + /** + * Runs the operation and, on success, flushes the operation's declarative `cacheInvalidation` + * tags - used on paths that never reach the normal cache-then-store flow below (a non-GET + * request, a GET request without an enabled response cache, or an item-operation security + * bypass). + * + * @param callable(): string $processOperation + */ + private function processAndInvalidateCache( + OperationInterface $operation, + array $route, + Request $request, + callable $processOperation, + ?ResponseInterface &$response, + mixed &$result = null + ): string { + $output = $processOperation(); + $this->flushInvalidationTagsAfterSuccessfulWrite($operation, $route, $request, $response, $result); + + return $output; + } + + /** + * The cacheable-request path (`GET` only, see `ResponseCacheService::buildEntryIdentifier()`): + * processes the operation and, for a successful, write-allowed response, stores the output + * under the collected content tags plus the operation's declarative `cache` `tags`. A `GET` + * operation never invalidates anything itself, so there is no flush step here - see + * `flushInvalidationTagsAfterSuccessfulWrite()`. + * + * @param callable(): string $processOperation + */ + private function processAndStore( + OperationInterface $operation, + array $route, + Request $request, + string $cacheEntryIdentifier, + callable $processOperation, + ?ResponseInterface &$response, + mixed &$result = null + ): string { + $this->cacheTagCollector->start(); + $this->seedResourceTableTag($operation); + try { + $output = $processOperation(); + } finally { + $cacheTags = $this->cacheTagCollector->stop(); + } + + if ( + ($response === null || $response->getStatusCode() === 200) + && $this->responseCacheService->isWriteAllowed($operation, $route, $request) + ) { + $this->storeWithTags($operation, $route, $request, $cacheEntryIdentifier, $output, $cacheTags, $result, $response); + } + + return $output; + } + + /** + * Combines the automatic content tags with the operation's declarative `cache` `tags`, + * `tagExpressions` and `memberTagExpressions`, then stores the entry. A `tagExpressions` (or + * `memberTagExpressions`) evaluation failure - an expression error, or a non-empty result that + * is not a valid tag - means the entry's invalidation contract cannot be guaranteed, so the + * entry is not stored at all (fail-closed, see + * `ResponseCacheService::evaluateStoreTagExpressions()`); the logging for that failure already + * happened there. + * + * @param string[] $cacheTags + */ + private function storeWithTags( + OperationInterface $operation, + array $route, + Request $request, + string $cacheEntryIdentifier, + string $output, + array $cacheTags, + mixed $result, + ?ResponseInterface &$response + ): void { + $responseCacheSettings = $operation->getResponseCacheSettings(); + $tagExpressions = $responseCacheSettings->getTagExpressions(); + $expressionTags = $tagExpressions === [] + ? [] + : $this->responseCacheService->evaluateStoreTagExpressions($tagExpressions, $operation, $route, $request); + if ($expressionTags === null) { + return; + } + + $memberTagExpressions = $responseCacheSettings->getMemberTagExpressions(); + $memberExpressionTags = $memberTagExpressions === [] + ? [] + : $this->evaluateMemberTagExpressions($memberTagExpressions, $operation, $route, $request, $result); + if ($memberExpressionTags === null) { + return; + } + + $literalTags = $this->filterValidTags($responseCacheSettings->getTags()); + $tags = array_values(array_unique([...$cacheTags, ...$literalTags, ...$expressionTags, ...$memberExpressionTags])); + $this->responseCacheService->store($cacheEntryIdentifier, $output, $tags, $responseCacheSettings->getLifetime()); + $this->responseCacheDebugHeaders->stored($response, $cacheEntryIdentifier, $tags); + } + + /** + * Evaluates `cache.memberTagExpressions` once per top-level result entity, each evaluation + * seeing the standard tag-expression variable set (see + * `ResponseCacheService::evaluateStoreTagExpressions()`) plus `object`, bound to that one + * entity. An item GET's `$result` is the single result entity, evaluated once; a collection + * GET's `$result` is an `AbstractCollectionResponse`, evaluated once per member, the resulting + * tags unioned and deduplicated - an empty collection therefore contributes zero evaluations + * and zero tags, without blocking the store. Fail-closed per member, mirroring + * `evaluateStoreTagExpressions()`: a single member's expression failure means the entry's + * invalidation contract cannot be guaranteed, so the whole entry is not stored (`null`) rather + * than a partial per-member tag set. + * + * @param string[] $memberTagExpressions + * @return string[]|null + */ + private function evaluateMemberTagExpressions( + array $memberTagExpressions, + OperationInterface $operation, + array $route, + Request $request, + mixed $result + ): ?array { + $tags = []; + foreach ($this->resolveTagExpressionMembers($operation, $result) as $member) { + $memberTags = $this->responseCacheService->evaluateStoreTagExpressions( + $memberTagExpressions, + $operation, + $route, + $request, + ['object' => $member] + ); + if ($memberTags === null) { + return null; + } + + $tags = [...$tags, ...$memberTags]; + } + + return array_values(array_unique($tags)); + } + + /** + * Resolves the top-level result into the `AbstractDomainObject` instances + * `memberTagExpressions` evaluates against: a collection GET's members (via + * `AbstractCollectionResponse::getMembers()`), or an item GET's single result entity. Any + * member that is not an `AbstractDomainObject` - a resource whose `entity` is not one, or a + * future non-entity collection member - is skipped with a logged warning rather than evaluated + * against. + * + * @return AbstractDomainObject[] + */ + private function resolveTagExpressionMembers(OperationInterface $operation, mixed $result): array + { + $candidates = $result instanceof AbstractCollectionResponse ? $result->getMembers() : [$result]; + + $members = []; + foreach ($candidates as $candidate) { + if (!$candidate instanceof AbstractDomainObject) { + $this->logger?->warning( + 't3api response cache memberTagExpressions skipped a result entry that is not an AbstractDomainObject', + ['operation' => $operation->getKey(), 'type' => get_debug_type($candidate)] + ); + + continue; + } + + $members[] = $candidate; + } + + return $members; + } + + /** + * Flushes the operation's declarative `cacheInvalidation` tags once a non-GET operation has + * run to completion without throwing - a null response (no cacheable content, e.g. a DELETE) + * or a 2xx status both count as success; anything else (a handler-reported error) skips the + * flush. A `GET` operation never flushes, even when a resource-level `cacheInvalidation` block + * cascades onto it - such a block is meant for the resource's write operations, and reading + * never invalidates anything. + * + * `$result` - the write operation's persisted entity, or `null` on a `DELETE` (or when a + * custom handler returns nothing) - is exposed to `tagExpressions` as `object` (fail-soft, like + * every other part of this evaluation, see `ResponseCacheService::evaluateFlushTagExpressions()`). + */ + private function flushInvalidationTagsAfterSuccessfulWrite( + OperationInterface $operation, + array $route, + Request $request, + ?ResponseInterface &$response, + mixed $result = null + ): void { + if ($operation->isMethodGet() || !$this->isSuccessfulResponse($response)) { + return; + } + + $cacheInvalidationSettings = $operation->getCacheInvalidationSettings(); + $tagExpressions = $cacheInvalidationSettings->getTagExpressions(); + $expressionTags = $tagExpressions === [] + ? [] + : $this->responseCacheService->evaluateFlushTagExpressions( + $tagExpressions, + $operation, + $route, + $request, + ['object' => $result] + ); + + $tags = array_values(array_unique([ + ...$this->filterValidTags($cacheInvalidationSettings->getTags()), + ...$expressionTags, + ])); + if ($tags === []) { + return; + } + + $this->responseCacheService->flushByTags($tags); + $this->responseCacheDebugHeaders->flushed($response, $tags); + } + + private function isSuccessfulResponse(?ResponseInterface $response): bool + { + if ($response === null) { + return true; + } + + $statusCode = $response->getStatusCode(); + + return $statusCode >= 200 && $statusCode < 300; + } + + /** + * Drops any literal tag the cache framework's allowed tag charset rejects - such a tag would + * make `FrontendInterface::set()`/`flushByTags()` throw, taking the whole request down with + * it, so it is skipped with a warning instead. + * + * @param string[] $tags + * @return string[] + */ + private function filterValidTags(array $tags): array + { + $validTags = []; + foreach ($tags as $tag) { + if (!$this->responseCacheService->isValidTag($tag)) { + $this->logger?->warning('t3api response cache tag is invalid - skipped', ['tag' => $tag]); + + continue; + } + + $validTags[] = $tag; + } + + return array_values(array_unique($validTags)); + } + + /** + * Seeds the collector with three tags before serialization runs, so a stored response always + * carries them - even an empty collection, which `CacheTagSubscriber` never sees a single + * entity for to derive a tag from. The bare `
` tag is a blanket lever: it is never the + * automatic flush target of a membership-changing write (see `FlushCacheAfterSaveListener`), + * but stays available for a manual/administrative "flush everything for this table" call, and + * is exactly what `t3api:cache:invalidate-expired` needs, since it can only ever detect that a + * table crossed a starttime/endtime threshold, never which uids did. The scope tag - + * `
--collection` on a collection response, `
--single` on an item response - is + * the precise automatic target for those writes, so invalidating a collection's membership + * never evicts an unrelated, still-correct cached item response on the same table. The `--` + * separator (rather than `_`, used by `
_`) is deliberate: a real TYPO3/Extbase + * table name never contains a hyphen, so `collection`/`single` as a table-name suffix could in + * principle collide with a genuine, unrelated table (e.g. `tx_shop_products_collection`) if `_` + * were used instead. Resources not backed by an `AbstractDomainObject` (e.g. future DTO + * resources) have no table to tag and are left TTL-only, consistent with the documented + * content-based tagging semantics. + */ + private function seedResourceTableTag(OperationInterface $operation): void + { + $entityClass = $operation->getApiResource()->getEntity(); + if (!is_subclass_of($entityClass, AbstractDomainObject::class)) { + return; + } + + $table = $this->dataMapper->getDataMap($entityClass)->getTableName(); + $scopeTag = $operation instanceof CollectionOperation ? $table . '--collection' : $table . '--single'; + $this->cacheTagCollector->addTags($table, $scopeTag); + } + + /** + * Evaluates the operation `security` expression on the cache path, before any cache + * read or write - a cache hit would otherwise skip the access check the operation + * handler normally performs. Returns the processor's output when the check itself + * determines the request must fall through to the normal (uncached) handler path + * instead of being gated here, or `null` when the caller may proceed with the + * regular cache read/miss flow. + * + * @param callable(): string $processOperation + * @throws OperationNotAllowedException + */ + protected function enforceOperationSecurity( + OperationInterface $operation, + array $route, + Request $request, + callable $processOperation, + ?ResponseInterface &$response + ): ?string { + if ($operation->getSecurity() === '') { + return null; + } + + if ($operation instanceof CollectionOperation) { + if (!$this->operationAccessChecker->isGranted($operation)) { + throw new OperationNotAllowedException($operation, 1574416639472); + } + + return null; + } + + return $this->enforceItemOperationSecurity($operation, $route, $request, $processOperation, $response); + } + + /** + * Item operation `security` expressions may reference the loaded entity via the + * `object` variable, which the collection path never has to provide. Evaluation is + * first attempted without it (the common case, e.g. checks against the current + * user); only when that fails is the entity loaded - mirroring + * `AbstractItemOperationHandler` - and the check retried with `object` in scope. + * + * @param callable(): string $processOperation + * @throws OperationNotAllowedException + */ + protected function enforceItemOperationSecurity( + OperationInterface $operation, + array $route, + Request $request, + callable $processOperation, + ?ResponseInterface &$response + ): ?string { + try { + $granted = $this->operationAccessChecker->isGranted($operation); + } catch (\Throwable $throwable) { + $this->logger?->warning( + sprintf( + 'Response cache security check for operation `%s` (`%s`) could not be evaluated without ' . + 'the loaded `%s` entity - the `security` expression references the request-scoped `object` ' . + 'variable, so the response cache path now loads the entity on every request to evaluate it. ' . + 'This is a performance cost; consider avoiding `object` access in `security` for cached item operations.', + $operation->getKey(), + $operation->getPath(), + $operation->getApiResource()->getEntity() + ), + ['exception' => $throwable] + ); + + $object = $this->loadItemOperationObject($operation, $route); + if ($object === null) { + // Falls through to the normal handler without ever reading the cache - reported as + // `miss` rather than left undecorated, since this response is still not served from + // a stored entry. + $this->responseCacheDebugHeaders->lookupOutcome($response, 'miss'); + + return $this->processAndInvalidateCache($operation, $route, $request, $processOperation, $response); + } + + $granted = $this->operationAccessChecker->isGranted($operation, ['object' => $object]); + } + + if (!$granted) { + throw new OperationNotAllowedException($operation, 1574411504130); + } + + return null; + } + + /** + * Mirrors `AbstractItemOperationHandler::handle()`'s entity retrieval. + */ + protected function loadItemOperationObject(OperationInterface $operation, array $route): ?AbstractDomainObject + { + $object = CommonRepository::getInstanceForOperation($operation)->findByUid((int)$route['id']); + + return $object instanceof AbstractDomainObject ? $object : null; + } +} diff --git a/Classes/Service/ResponseCacheDebugHeaders.php b/Classes/Service/ResponseCacheDebugHeaders.php new file mode 100644 index 0000000..91dddf7 --- /dev/null +++ b/Classes/Service/ResponseCacheDebugHeaders.php @@ -0,0 +1,100 @@ +isDevelopment()` (see `isDevelopmentContext()`) - every method below + * is a no-op, leaving `$response` untouched, both when the gate is closed and when `$response` + * itself is `null` (a caller that omits it, mirroring `OperationResponseCache::resolve()`). + */ +class ResponseCacheDebugHeaders +{ + private const HEADER_CACHE = 'X-T3api-Cache'; + private const HEADER_CACHE_IDENTIFIER = 'X-T3api-Cache-Identifier'; + private const HEADER_CACHE_TAGS = 'X-T3api-Cache-Tags'; + private const HEADER_CACHE_FLUSHED_TAGS = 'X-T3api-Cache-Flushed-Tags'; + + /** + * A cache HIT: the response is about to be served straight from the stored entry. + */ + public function hit(?ResponseInterface &$response, string $cacheEntryIdentifier): void + { + if (!$this->shouldDecorate($response)) { + return; + } + + $response = $response + ->withHeader(self::HEADER_CACHE, 'hit') + ->withHeader(self::HEADER_CACHE_IDENTIFIER, $cacheEntryIdentifier); + } + + /** + * A cache lookup that did not serve a HIT: `miss` (the lookup ran and found nothing) or + * `bypass` (`readCondition` evaluated falsy, so the lookup never ran). Either way the response + * may still end up stored - see `stored()`, called separately once that is known. + */ + public function lookupOutcome(?ResponseInterface &$response, string $outcome): void + { + if (!$this->shouldDecorate($response)) { + return; + } + + $response = $response->withHeader(self::HEADER_CACHE, $outcome); + } + + /** + * The response was actually written to the cache - the entry identifier it was stored under, + * and the full tag set it was stored with, in the same deterministic order it was stored in. + * + * @param string[] $tags + */ + public function stored(?ResponseInterface &$response, string $cacheEntryIdentifier, array $tags): void + { + if (!$this->shouldDecorate($response)) { + return; + } + + $response = $response + ->withHeader(self::HEADER_CACHE_IDENTIFIER, $cacheEntryIdentifier) + ->withHeader(self::HEADER_CACHE_TAGS, implode(',', $tags)); + } + + /** + * A `cacheInvalidation` flush actually ran - the tags it flushed. Omitted entirely when + * `$tags` is empty, since a flush that flushed nothing is not worth surfacing. + * + * @param string[] $tags + */ + public function flushed(?ResponseInterface &$response, array $tags): void + { + if ($tags === [] || !$this->shouldDecorate($response)) { + return; + } + + $response = $response->withHeader(self::HEADER_CACHE_FLUSHED_TAGS, implode(',', $tags)); + } + + private function shouldDecorate(?ResponseInterface $response): bool + { + return $response !== null && $this->isDevelopmentContext(); + } + + /** + * Isolated behind its own method - not called inline from the four methods above - so a unit + * test can stub the development gate by overriding this one method, without touching TYPO3's + * global `Environment` state. + */ + protected function isDevelopmentContext(): bool + { + return Environment::getContext()->isDevelopment(); + } +} diff --git a/Classes/Service/ResponseCacheService.php b/Classes/Service/ResponseCacheService.php new file mode 100644 index 0000000..194e324 --- /dev/null +++ b/Classes/Service/ResponseCacheService.php @@ -0,0 +1,504 @@ +getMethod() !== Request::METHOD_GET) { + return null; + } + + $responseCacheSettings = $operation->getResponseCacheSettings(); + if (!$responseCacheSettings->isEnabled()) { + return null; + } + + $queryParams = $request->query->all(); + unset($queryParams[ResourceEnhancer::PARAMETER_NAME]); + + // `FilterAccessChecker` is not re-evaluated on the cache path (unlike the operation-level + // `security` expression, see `OperationResponseCache`) - a query targeting a filter guarded + // by a security condition must therefore bypass the cache entirely. + if ($this->hasSecuredFilterParameter($operation, $queryParams)) { + return null; + } + + $allowedQueryParams = array_intersect_key( + $queryParams, + array_flip($this->getAllowedParameterNames($operation)) + ); + ksort($allowedQueryParams); + + $keyParts = [ + $this->getSiteIdentifier(), + $this->getLanguageId(), + $request->getPathInfo(), + http_build_query($allowedQueryParams), + ]; + + // An empty `identifierExpressions` array (the default) leaves the loop below a no-op, so + // entries built without this setting hash identically to before it existed. + foreach ($responseCacheSettings->getIdentifierExpressions() as $identifierExpression) { + $identifierExpressionValue = $this->evaluateIdentifierExpression($identifierExpression, $operation, $route, $request); + if ($identifierExpressionValue === null) { + return null; + } + $keyParts[] = $identifierExpressionValue; + } + + // md5() here is a non-cryptographic cache key derivation, not a security control - there is + // no secret to protect and no adversarial value in collision resistance, since `security` is + // still fully re-evaluated on every cache hit (see `OperationResponseCache`), so a collision + // could not bypass access control. + return md5(implode('|', $keyParts)); // NOSONAR php:S4790 - weak hash algorithm, not used in a sensitive context here + } + + /** + * Whether the response for given request may be served from cache. Controlled + * by the `readCondition` expression - an empty condition always allows reading. + */ + public function isReadAllowed(OperationInterface $operation, array $route, Request $request): bool + { + return $this->isConditionSatisfied( + $operation->getResponseCacheSettings()->getReadCondition(), + $operation, + $route, + $request + ); + } + + /** + * Whether the response for given request may be stored in cache. Controlled + * by the `writeCondition` expression - an empty condition always allows storing. + */ + public function isWriteAllowed(OperationInterface $operation, array $route, Request $request): bool + { + return $this->isConditionSatisfied( + $operation->getResponseCacheSettings()->getWriteCondition(), + $operation, + $route, + $request + ); + } + + public function get(string $entryIdentifier): ?string + { + try { + $data = $this->cache->get($entryIdentifier); + } catch (\Throwable $throwable) { + $this->logger?->error('t3api response cache read failed', ['exception' => $throwable]); + + return null; + } + + return is_string($data) ? $data : null; + } + + /** + * @param string[] $tags + */ + public function store(string $entryIdentifier, string $output, array $tags, int $lifetime): void + { + try { + $this->cache->set($entryIdentifier, $output, $tags, $lifetime); + } catch (\Throwable $throwable) { + $this->logger?->error('t3api response cache write failed', ['exception' => $throwable]); + } + } + + /** + * Flushes every stored entry carrying any of the given tags - used for the declarative + * `cacheInvalidation` `tags` setting, evaluated after a non-GET operation completes + * successfully. + * + * @param string[] $tags + */ + public function flushByTags(array $tags): void + { + try { + $this->cache->flushByTags($tags); + } catch (\Throwable $throwable) { + $this->logger?->error('t3api response cache flush failed', ['exception' => $throwable, 'tags' => $tags]); + } + } + + /** + * Whether the given string is a valid cache tag for this cache's frontend - used to validate + * the declarative `cache`/`cacheInvalidation` `tags` literals, since an invalid tag reaching + * `set()`/`flushByTags()` directly would throw. + */ + public function isValidTag(string $tag): bool + { + return $this->cache->isValidTag($tag); + } + + /** + * Evaluates the `cache.tagExpressions` setting for a response about to be stored. Fail-closed, + * mirroring `evaluateIdentifierExpression()`'s philosophy: an entry whose invalidation contract + * cannot be guaranteed - because an expression errored, or produced a tag the cache frontend + * rejects - must not be stored at all, so a single failing entry returns `null` (store nothing) + * rather than a partial tag set. An empty string result is a valid "no tag from this + * expression" result (the idiom for conditional tagging) and contributes nothing. + * + * `$additionalVariables` is how `OperationResponseCache` reuses this same fail-closed + * evaluation for `cache.memberTagExpressions`, adding `object` (the member being evaluated) on + * top of the standard variable set - `cache.tagExpressions` itself is always called without it, + * since it is evaluated once for the whole response, before any entity is known to exist. + * + * @param string[] $tagExpressions + * @param array $additionalVariables + * @return string[]|null the resolved, non-empty tags, or null when the entry must not be stored + */ + public function evaluateStoreTagExpressions( + array $tagExpressions, + OperationInterface $operation, + array $route, + Request $request, + array $additionalVariables = [] + ): ?array { + $tags = []; + foreach ($tagExpressions as $tagExpression) { + $tag = $this->evaluateStoreTagExpression($tagExpression, $operation, $route, $request, $additionalVariables); + if ($tag === null) { + return null; + } + + if ($tag !== '') { + $tags[] = $tag; + } + } + + return $tags; + } + + /** + * Evaluates the `cacheInvalidation.tagExpressions` setting after a successful write. Fail-soft, + * unlike the store side: the write itself already succeeded, so a broken expression must not + * break the response - an evaluation error or an invalid resulting tag merely skips that one + * tag, logged as a warning, while every other tag (static or expression-derived) still flushes. + * + * `$additionalVariables` is how `OperationResponseCache` adds `object` - the persisted entity of + * the write operation, or `null` on a `DELETE` (or when a custom handler returns nothing) - on + * top of the standard variable set. + * + * @param string[] $tagExpressions + * @param array $additionalVariables + * @return string[] + */ + public function evaluateFlushTagExpressions( + array $tagExpressions, + OperationInterface $operation, + array $route, + Request $request, + array $additionalVariables = [] + ): array { + $tags = []; + foreach ($tagExpressions as $tagExpression) { + $tag = $this->evaluateFlushTagExpression($tagExpression, $operation, $route, $request, $additionalVariables); + if ($tag !== null && $tag !== '') { + $tags[] = $tag; + } + } + + return array_values(array_unique($tags)); + } + + /** + * Store-side single-expression evaluation: logs at error level and signals "do not store" + * (`null`) both when the expression itself fails and when it produces a non-empty tag the + * cache frontend rejects. + * + * @param array $additionalVariables + */ + private function evaluateStoreTagExpression( + string $tagExpression, + OperationInterface $operation, + array $route, + Request $request, + array $additionalVariables = [] + ): ?string { + try { + $tag = $this->evaluateTagExpression($tagExpression, $operation, $route, $request, $additionalVariables); + } catch (\Throwable $throwable) { + $this->logger?->error('t3api response cache tag expression evaluation failed - entry not stored', [ + 'tagExpression' => $tagExpression, + 'exception' => $throwable, + ]); + + return null; + } + + if ($tag !== '' && !$this->isValidTag($tag)) { + $this->logger?->error('t3api response cache tag expression produced an invalid tag - entry not stored', [ + 'tagExpression' => $tagExpression, + 'tag' => $tag, + ]); + + return null; + } + + return $tag; + } + + /** + * Flush-side single-expression evaluation: logs at warning level and signals "skip this tag" + * (`null`) both when the expression itself fails and when it produces a non-empty tag the + * cache frontend rejects - the other declared tags still flush. + * + * @param array $additionalVariables + */ + private function evaluateFlushTagExpression( + string $tagExpression, + OperationInterface $operation, + array $route, + Request $request, + array $additionalVariables = [] + ): ?string { + try { + $tag = $this->evaluateTagExpression($tagExpression, $operation, $route, $request, $additionalVariables); + } catch (\Throwable $throwable) { + $this->logger?->warning('t3api response cache tag expression evaluation failed - skipped', [ + 'tagExpression' => $tagExpression, + 'exception' => $throwable, + ]); + + return null; + } + + if ($tag !== '' && !$this->isValidTag($tag)) { + $this->logger?->warning('t3api response cache tag expression produced an invalid tag - skipped', [ + 'tagExpression' => $tagExpression, + 'tag' => $tag, + ]); + + return null; + } + + return $tag; + } + + /** + * Evaluates a single `tagExpressions` entry with the same resolver and variable set as + * `readCondition`/`identifierExpressions`, plus whatever `$additionalVariables` the caller adds + * on top (see `evaluateStoreTagExpressions()`/`evaluateFlushTagExpressions()`). Left to throw on + * evaluation failure - the store and flush sides apply their own failure policy (see + * `evaluateStoreTagExpression()` and `evaluateFlushTagExpression()`). + * + * @param array $additionalVariables + */ + private function evaluateTagExpression( + string $tagExpression, + OperationInterface $operation, + array $route, + Request $request, + array $additionalVariables = [] + ): string { + return (string)$this->getConditionResolver()->evaluate( + $tagExpression, + $this->getConditionVariables($operation, $route, $request, $additionalVariables) + ); + } + + /** + * `clearCachePostProc` hook - flushes response cache entries when caches + * are cleared from the TYPO3 backend. + */ + public function clearCachePostProc(array $params): void + { + if (($params['cacheCmd'] ?? '') === 'all') { + $this->cache->flush(); + + return; + } + + $tags = array_keys($params['tags'] ?? []); + if ($tags !== []) { + $this->cache->flushByTags($tags); + } + } + + /** + * @param array $queryParams + */ + protected function hasSecuredFilterParameter(OperationInterface $operation, array $queryParams): bool + { + if (!$operation instanceof CollectionOperation) { + return false; + } + + foreach ($operation->getFilters() as $filter) { + if (!empty($filter->getStrategy()->getCondition()) && array_key_exists($filter->getParameterName(), $queryParams)) { + return true; + } + } + + return false; + } + + /** + * @return string[] + */ + protected function getAllowedParameterNames(OperationInterface $operation): array + { + if (!$operation instanceof CollectionOperation) { + return []; + } + + $allowedParameterNames = [ + $operation->getPagination()->getPageParameterName(), + $operation->getPagination()->getItemsPerPageParameterName(), + $operation->getPagination()->getEnabledParameterName(), + ]; + foreach ($operation->getFilters() as $filter) { + $allowedParameterNames[] = $filter->getParameterName(); + } + + return array_unique($allowedParameterNames); + } + + /** + * Evaluates a single entry of the `identifierExpressions` cache setting with the same resolver + * and variable set as `readCondition`/`writeCondition`. Mirrors their fail-closed pattern on + * evaluation errors: an identifier that could not be determined must not fall back to one + * shared with requests it was meant to be kept separate from, so `null` here means "do not + * cache this request" rather than "treat as empty". A null/false expression result, on the + * other hand, casts to `''` and is a perfectly valid - if unhelpful - identifier contribution. + */ + protected function evaluateIdentifierExpression( + string $identifierExpression, + OperationInterface $operation, + array $route, + Request $request + ): ?string { + try { + return (string)$this->getConditionResolver()->evaluate( + $identifierExpression, + $this->getConditionVariables($operation, $route, $request) + ); + } catch (\Throwable $throwable) { + $this->logger?->error('t3api response cache identifier expression evaluation failed', [ + 'identifierExpression' => $identifierExpression, + 'exception' => $throwable, + ]); + + return null; + } + } + + /** + * Evaluates a cache condition expression. Evaluation errors count as "not + * satisfied" (fail-closed) - a broken expression must never cause a stale + * or unauthorized response to be served or stored. + */ + protected function isConditionSatisfied( + string $condition, + OperationInterface $operation, + array $route, + Request $request + ): bool { + if ($condition === '') { + return true; + } + + try { + return (bool)$this->getConditionResolver()->evaluate( + $condition, + $this->getConditionVariables($operation, $route, $request) + ); + } catch (\Throwable $throwable) { + $this->logger?->error('t3api response cache condition evaluation failed - treated as not satisfied', [ + 'condition' => $condition, + 'exception' => $throwable, + ]); + + return false; + } + } + + /** + * Mirrors the variable set of `AbstractAccessChecker` (used by the `security` + * expressions) and additionally exposes the Symfony `request` object and the + * matched route parameters. `$additionalVariables` layers on top of this standard set - used + * exclusively by the tag-expression evaluators to add `object` (see + * `evaluateStoreTagExpressions()`/`evaluateFlushTagExpressions()`); every other caller + * (conditions, `identifierExpressions`, the response-level `tagExpressions`) leaves it empty, + * since `object` is never available there (see :ref:`response-cache-conditions`). + * + * @param array $route + * @param array $additionalVariables + * @return array + */ + protected function getConditionVariables( + OperationInterface $operation, + array $route, + Request $request, + array $additionalVariables = [] + ): array { + // `_route` carries the spl_object_hash-based route name - nondeterministic across + // processes, so exposing it to cache expressions would break identifier sharing. + unset($route['_route']); + + return [ + 'request' => $request, + 'route' => $route, + 't3apiOperation' => $operation, + ...ExpressionLanguageService::getUserVariables($this->context), + ...$additionalVariables, + ]; + } + + protected function getConditionResolver(): Resolver + { + return $this->conditionResolver ??= GeneralUtility::makeInstance(Resolver::class, 't3api', []); + } + + protected function getSiteIdentifier(): string + { + $site = $this->getTypo3Request()?->getAttribute('site'); + + return $site instanceof Site ? $site->getIdentifier() : ''; + } + + protected function getLanguageId(): string + { + $language = $this->getTypo3Request()?->getAttribute('language'); + + return $language instanceof SiteLanguage ? (string)$language->getLanguageId() : ''; + } + + protected function getTypo3Request(): ?ServerRequestInterface + { + return $GLOBALS['TYPO3_REQUEST'] ?? null; + } +} diff --git a/Classes/Service/SerializerService.php b/Classes/Service/SerializerService.php index d5c9fe6..d14f386 100644 --- a/Classes/Service/SerializerService.php +++ b/Classes/Service/SerializerService.php @@ -36,6 +36,8 @@ class SerializerService implements SingletonInterface { + private ?SerializerBuilder $serializerBuilder = null; + public function __construct( protected readonly SerializationContextBuilder $serializationContextBuilder, protected readonly DeserializationContextBuilder $deserializationContextBuilder @@ -97,10 +99,8 @@ public function deserialize(string $data, string $type, ?DeserializationContext public function getSerializerBuilder(): SerializerBuilder { - static $serializerBuilder; - - if (empty($serializerBuilder)) { - $serializerBuilder = SerializerBuilder::create() + if ($this->serializerBuilder === null) { + $this->serializerBuilder = SerializerBuilder::create() ->setCacheDir(self::getSerializerCacheDirectory()) ->setDebug(self::isDebugMode()) ->configureHandlers(static function (HandlerRegistry $registry): void { @@ -131,7 +131,7 @@ public function getSerializerBuilder(): SerializerBuilder ->setExpressionEvaluator(self::getExpressionEvaluator()); } - return clone $serializerBuilder; + return clone $this->serializerBuilder; } public function getMetadataFactory(): MetadataFactoryInterface diff --git a/Configuration/Services.yaml b/Configuration/Services.yaml index 1e1bd49..c70b0d8 100644 --- a/Configuration/Services.yaml +++ b/Configuration/Services.yaml @@ -115,6 +115,9 @@ services: SourceBroker\T3api\Serializer\Subscriber\CurrentFeUserSubscriber: public: true + SourceBroker\T3api\Serializer\Subscriber\CacheTagSubscriber: + public: true + SourceBroker\T3api\Response\HydraCollectionResponse: public: true @@ -140,3 +143,48 @@ services: - name: event.listener identifier: 't3api/EnrichPageCacheIdentifierParametersEventListener' event: TYPO3\CMS\Frontend\Event\BeforePageCacheIdentifierIsHashedEvent + + cache.t3api_response: + class: TYPO3\CMS\Core\Cache\Frontend\FrontendInterface + factory: ['@TYPO3\CMS\Core\Cache\CacheManager', 'getCache'] + arguments: ['t3api_response'] + + SourceBroker\T3api\Service\ResponseCacheService: + public: true + arguments: + $cache: '@cache.t3api_response' + + SourceBroker\T3api\Hook\ResponseCacheDataHandlerHook: + public: true + + SourceBroker\T3api\EventListener\FlushCacheAfterSaveListener: + arguments: + $cache: '@cache.t3api_response' + tags: + - name: event.listener + identifier: 't3api/FlushCacheAfterSaveListenerAfterUpdated' + event: SourceBroker\T3api\Event\RecordUpdatedEvent + method: afterUpdated + - name: event.listener + identifier: 't3api/FlushCacheAfterSaveListenerAfterDeleted' + event: SourceBroker\T3api\Event\RecordDeletedEvent + method: afterDeleted + + SourceBroker\T3api\Command\InvalidateExpiredResponseCacheCommand: + arguments: + $cache: '@cache.t3api_response' + + SourceBroker\T3api\EventListener\PersistenceEventListener: + tags: + - name: event.listener + identifier: 't3api/PersistenceEventListenerEntityAdded' + event: TYPO3\CMS\Extbase\Event\Persistence\EntityAddedToPersistenceEvent + method: entityAdded + - name: event.listener + identifier: 't3api/PersistenceEventListenerEntityUpdated' + event: TYPO3\CMS\Extbase\Event\Persistence\EntityUpdatedInPersistenceEvent + method: entityUpdated + - name: event.listener + identifier: 't3api/PersistenceEventListenerEntityRemoved' + event: TYPO3\CMS\Extbase\Event\Persistence\EntityRemovedFromPersistenceEvent + method: entityRemoved diff --git a/Documentation/Index.rst b/Documentation/Index.rst index be620c0..8c44f75 100644 --- a/Documentation/Index.rst +++ b/Documentation/Index.rst @@ -48,6 +48,7 @@ If you find an error or something is missing, please: `Report a Problem Operations/Index Filtering/Index Pagination/Index + ResponseCache/Index Security/Index Serialization/Index Integration/Index diff --git a/Documentation/ResponseCache/Index.rst b/Documentation/ResponseCache/Index.rst new file mode 100644 index 0000000..f632f98 --- /dev/null +++ b/Documentation/ResponseCache/Index.rst @@ -0,0 +1,1109 @@ +.. _response-cache: + +============== +Response cache +============== + +T3api can cache whole GET responses (collections and items) in the TYPO3 +Caching Framework. Entries are tagged with every record they contain - +including nested relations - and are invalidated automatically when a +record is changed or deleted via the TYPO3 backend (DataHandler) or via +Extbase persistence. See :ref:`response-cache-hit-semantics` for exactly +what runs, and does not run, on a cache hit. + +Enabling +======== + +Caching is opt-in per resource model via the ``cache`` key of the +``@ApiResource`` ``attributes`` array - the same array used for pagination, +persistence and upload settings: + +.. code-block:: php + + use SourceBroker\T3api\Annotation\ApiResource; + + /** + * @ApiResource( + * attributes={ + * "cache"={ + * "lifetime"=3600, + * "readCondition"="request.query.get('refresh') == null", + * "identifierExpressions"={ + * "context.getPropertyFromAspect('frontend.user', 'groupIds', '')" + * } + * } + * } + * ) + */ + class Book extends \TYPO3\CMS\Extbase\DomainObject\AbstractEntity + { + } + +There is no separate caching annotation - all cache configuration lives +inside this ``attributes={"cache"={...}}`` block, at resource level and, +as described next, per operation. Write-triggered cache invalidation is +configured separately, via the sibling +``attributes={"cacheInvalidation"={...}}`` block, see +:ref:`response-cache-invalidation`. + +.. warning:: + If your resource output depends on the current user (or any other + request context beyond URL and declared filters) you **must** declare a + suitable ``identifierExpressions`` entry (typically reading a TYPO3 + Context aspect via ``context``, see "Cache key" below) - otherwise the + first user's response is served to everyone. + +Configuration reference +======================== + +The ``cache`` block accepts the following keys: + +- ``enabled`` - whether caching is active, see the presence rule below + (default: ``false``). +- ``lifetime`` - entry lifetime in seconds (default: ``86400``). +- ``readCondition`` / ``writeCondition`` - Symfony expressions gating cache + read and write, see :ref:`response-cache-conditions`. +- ``tags`` - extra tags added to a stored entry, on top of the automatic + content tags and the resource table seed (default: none). See + :ref:`response-cache-declarative-tags`. +- ``tagExpressions`` - Symfony expressions whose non-empty string results are + each added as an extra tag to a stored entry, alongside ``tags`` (default: + none). See :ref:`response-cache-tag-expressions`. +- ``memberTagExpressions`` - like ``tagExpressions``, but evaluated once per + top-level result entity instead of once per response (default: none). See + :ref:`response-cache-tag-expressions`. +- ``identifierExpressions`` - Symfony expressions whose string results are + each appended to the cache entry identifier (default: none), see "Cache + key" below. + +Write-triggered invalidation - flushing tags once a write operation +completes - is *not* a ``cache`` key: it is a separate, sibling +``attributes`` block, ``cacheInvalidation``, see +:ref:`response-cache-invalidation` below. A ``GET`` operation may only +declare ``cache``, and a non-``GET`` operation may only declare +``cacheInvalidation`` - declaring the wrong one explicitly on a specific +operation is rejected as soon as routes are built for the resource, see +:ref:`response-cache-invalidation-fail-fast`. + +The ``identifierExpressions`` key varies the cache entry identifier by +something the request path and declared pagination/filter parameters do not +otherwise capture - a request header, or a TYPO3 Context aspect via the +``context`` variable (see :ref:`response-cache-conditions`): + +.. code-block:: php + + /** + * @ApiResource( + * attributes={ + * "cache"={ + * "identifierExpressions"={ + * "request.headers.get('X-App-Version')", + * "context.getPropertyFromAspect('frontend.user', 'groupIds', '')" + * } + * } + * } + * ) + */ + +Two requests differing in any one of the declared expressions' results +produce distinct cache entries; two requests agreeing on every expression's +result share the same entry. Each entry is evaluated by the same expression +engine and variable set as ``readCondition``/``writeCondition`` (see +:ref:`response-cache-conditions`) - a failure of *any* one of them is +treated the same way as a failed condition: the request is not cached, and +the error is logged, regardless of whether the other expressions would have +evaluated fine. Leaving the array empty (the default) leaves the identifier +exactly as it would be without this setting. + +.. note:: + A non-empty ``cache`` block enables caching automatically - there is no + need to add ``"enabled"=true`` yourself. An empty (or absent) ``cache`` + block leaves caching disabled. An explicit ``"enabled"`` key always wins + over this rule, in either direction, so ``"cache"={"enabled"=false}`` + still disables caching even when other keys are also given. This applies + both at resource level and, as described next, per operation. + +Per-operation override +======================= + +The resource-level ``cache`` block is the base setting. Every operation +(item and collection) inherits it, but can override individual keys via its +own ``attributes={"cache"={...}}`` - only the given keys are overridden, the +rest cascade down from the resource. This allows disabling caching (or +changing its behaviour) for a single operation while leaving the others +cached: + +.. code-block:: php + + /** + * @ApiResource( + * attributes={ + * "cache"={"lifetime"=3600} + * }, + * collectionOperations={ + * "get"={ + * "method"="GET", + * "path"="/books", + * }, + * }, + * itemOperations={ + * "get"={ + * "method"="GET", + * "path"="/books/{id}", + * "attributes"={ + * "cache"={ + * "enabled"=false, + * }, + * }, + * }, + * }, + * ) + */ + class Book extends \TYPO3\CMS\Extbase\DomainObject\AbstractEntity + { + } + +Here the collection ``GET`` stays cached with the resource-level 3600s +lifetime, while the item ``GET`` is never cached. The overridable keys are +``enabled``, ``lifetime``, ``readCondition``, ``writeCondition``, +``identifierExpressions``, ``tags``, ``tagExpressions`` and +``memberTagExpressions`` - same names and semantics as the resource-level +``cache`` block above. The sibling ``cacheInvalidation`` block cascades the +same way, per its own keys (``tags`` and ``tagExpressions``), see +:ref:`response-cache-invalidation` below. + +The presence rule from the note above also applies per operation, and works +the other way round too: an operation can enable caching on its own even +when the resource declares no ``cache`` block at all (so the resource-level +default stays disabled): + +.. code-block:: php + + /** + * @ApiResource( + * itemOperations={ + * "get"={ + * "path"="/books/{id}", + * "attributes"={ + * "cache"={"lifetime"=60}, + * }, + * }, + * }, + * ) + */ + class Book extends \TYPO3\CMS\Extbase\DomainObject\AbstractEntity + { + } + +Only the item ``GET`` above is cached; any other operation on the same +resource is not. + +Cache key +========= + +The cache entry identifier is built from the site identifier, language id, +request path, declared pagination/filter parameters (see :ref:`pagination` +and :ref:`filtering`) and, when declared, every ``identifierExpressions`` +result, in the declared order (see "Configuration reference" above). +Unknown query parameters do not create separate entries. + +.. note:: + Declared parameters are kept as given, including falsy values: a request + with ``?active=0`` produces a different cache entry than the same request + without ``active`` at all. Treating ``"0"`` or an empty string as if the + parameter were absent would incorrectly merge distinct requests into one + cache entry. + +.. _response-cache-conditions: + +Conditions +========== + +``readCondition`` is evaluated before the cache lookup. An empty condition +(the default) always allows reading. When the expression evaluates to a +falsy value the response is not served from cache for this request - the +operation is processed normally and the fresh result may still be stored. +This enables a refresh mechanism: + +.. code-block:: php + + /** + * @ApiResource( + * attributes={ + * "cache"={"readCondition"="request.query.get('refresh') == null"} + * } + * ) + */ + +A request carrying ``?refresh=1`` skips the cache lookup, produces a fresh +response and (with an empty or satisfied ``writeCondition``) overwrites the +cached entry, so subsequent requests are served the refreshed data. + +``writeCondition`` is evaluated before storing a response. An empty +condition always allows storing; a falsy result skips the store. + +Both conditions, every ``identifierExpressions`` entry (see "Cache key" +above) and every ``tagExpressions`` entry (see +:ref:`response-cache-tag-expressions`) are evaluated by the same t3api +expression engine used for the ``security`` expressions and see the same +variables - ``backend`` and ``frontend`` (current user information), +``t3apiOperation`` (the matched operation), ``route`` (the matched route +parameters, as a plain array - e.g. ``route['id']`` for ``/books/{id}``, or +``route['recipeId']`` for ``/recipes/{recipeId}/favorite``; the router's +internal ``_route`` name is never included), ``context`` (the TYPO3 ``Context`` +object) - plus the Symfony ``request`` object and TYPO3's default expression +variables (``applicationContext``, ``typo3``, ``date``, ``features``). Any +expression-language provider registered for the ``t3api`` namespace via +``Configuration/ExpressionLanguage.php`` (TYPO3's standard +expression-provider mechanism) contributes its variables to +``readCondition``/``writeCondition``/``identifierExpressions``/``tagExpressions`` +automatically, too - the same variables already available to ``security`` +expressions. + +.. note:: + None of the expressions on this page ever see ``object`` - the loaded + entity variable ``security`` expressions may reference (see "Access + control" below). Two *other* response cache expressions do get it, under + different names/semantics: ``cache.memberTagExpressions`` and + ``cacheInvalidation.tagExpressions``, see + :ref:`response-cache-tag-expressions` for the full availability matrix. + +.. note:: + ``context`` mirrors the variable of the same name TYPO3 core itself + exposes to TypoScript conditions - the same object, supporting the same + ``context.getPropertyFromAspect('name', 'property', default)`` call. It + is unrelated to the ``context`` variable seen inside JMS serializer + metadata expressions (``exclude_if``, virtual properties), which JMS + injects per-call as its own ``SerializationContext``/ + ``DeserializationContext`` - the two expression surfaces have disjoint + variable sets, and provider variables such as this one do not leak into + serializer expressions. + +.. note:: + Conditions fail closed: an expression that cannot be evaluated counts as + falsy (no cache read, no cache write) and the error is logged. + +Examples +======== + +**Cache only anonymous traffic** - some responses serve user-specific +fields (e.g. a personalised greeting or favourite flag) that must stay +fresh for a logged-in visitor, while everyone else can safely share one +cached response: + +.. code-block:: php + + /** + * @ApiResource( + * attributes={ + * "cache"={ + * "readCondition"="!user.isLoggedIn()", + * "writeCondition"="!user.isLoggedIn()" + * } + * } + * ) + */ + +``user`` here is not a built-in t3api variable - it is a project-provided +expression variable, registered via a TYPO3 expression-language provider for +the ``t3api`` namespace (see :ref:`customization_expression-language`), the +same one used in ``security`` expressions. With both conditions in place, a +logged-in visitor's request is neither served from, nor stored in, the +cache - it always runs the operation fresh - while anonymous visitors share +the cached entry. + +**Refresh/warm-up** - reusing the ``readCondition`` from the mechanism above, +a scheduler-driven job can keep an entry warm ahead of real traffic by +periodically requesting it with ``?refresh=1``: the request bypasses the +cache lookup but its result is still stored (``writeCondition`` is left +empty), so the *next* real visitor - without ``?refresh=1`` - gets an +already-fresh cache hit instead of triggering the rebuild themselves: + +.. code-block:: php + + /** + * @ApiResource( + * attributes={ + * "cache"={"readCondition"="request.query.get('refresh') == null"} + * } + * ) + */ + +**Debug bypass** - a request investigated with ``?debug=1`` should reflect +the current state exactly, and must not pollute the cache with a +debug-flavoured response for later, real requests: + +.. code-block:: php + + /** + * @ApiResource( + * attributes={ + * "cache"={ + * "readCondition"="request.query.get('debug') == null", + * "writeCondition"="request.query.get('debug') == null" + * } + * } + * ) + */ + +A request carrying ``?debug=1`` neither reads from, nor writes to, the +cache - it is entirely bypassed for the duration of that request. + +Access control +============== + +Operations that declare a ``security`` expression **are** cached. ``security`` +is evaluated on every request before a response may be served from - or written +to - cache, by the same ``OperationAccessChecker`` the (uncached) operation +handler itself uses. A denied check throws exactly the exception the handler +would have thrown, regardless of whether the request would otherwise have +been a cache hit or a miss: a cache hit never bypasses ``security``. + +For item operations whose ``security`` expression references the loaded +entity via the ``object`` variable, evaluating the expression without an +entity fails; in that case the response cache path loads the entity - the +same way the item operation handler does - and evaluates the check again +with ``object`` in scope. This is logged as a warning, since it means the +entity is now loaded on *every* request to that operation, cached or not - +consider avoiding ``object`` access in ``security`` for cached item +operations, or disabling caching for it, if this cost is unacceptable. If +the entity cannot be found, the request falls through to the normal +(uncached) handler path, which produces its usual not-found response. + +``security_post_denormalize`` is never evaluated on the cache path: it only +applies to write operations (POST/PUT/PATCH), which the response cache does +not handle in the first place (only ``GET`` requests are cacheable at all). + +A request bypasses the cache entirely whenever it targets a filter (see +:ref:`filtering`) whose strategy carries a non-empty ``condition`` (evaluated +by ``FilterAccessChecker``) - e.g. a strategy declared as +``{"name": "partial", "condition": "is_granted('ROLE_ADMIN')"}`` - since that +check is not re-evaluated on the cache path. + +Tags and invalidation +====================== + +Invalidation is automatic on record create/update/delete through the TYPO3 +backend (DataHandler) and through Extbase persistence. "Flush all caches" +clears the response cache too. + +Tags come from three sources: the resource's own ``
`` tag; a scope +tag - ``
--collection`` on a collection response, ``
--single`` +on an item response; and per-record ``
_`` tags. The ``
`` +and scope tags are both seeded before serialization runs and are therefore +present on every response regardless of what it actually contains +(including an empty collection); the per-record tags are added for the +Extbase entities actually serialized into the response, including nested +relations. A resource whose ``entity`` is not an ``AbstractDomainObject`` +subclass (e.g. a future DTO-based resource) has nothing to seed the +resource-level ``
``/scope tags with, so such a response carries only +the per-record tags of whatever entities it actually happens to contain (if +any, via nested relations); with no entities in the response at all it +carries no tags and only expires by ``lifetime``. + +.. note:: + The double-hyphen in the scope tags is deliberate: a real TYPO3/Extbase + table name never contains a hyphen, so ``collection``/``single`` as a + table-name suffix could in principle collide with a genuine, unrelated + table (e.g. ``tx_shop_products_collection``) if the ``_`` separator used + by ``
_`` were reused here instead. + +.. note:: + The bare ``
`` tag is deliberately never the *automatic* flush + target of the per-record invalidation below - only the more precise + ``
--collection``/``
--single`` and ``
_`` tags + are, so that invalidating one record's collection membership never + evicts an unrelated, still-correct cached item response on the same + table. The ``
`` tag remains available as a manual/administrative + "flush everything cached for this table, items and collections alike" + lever (``$cache->flushByTags([$table])``), and is exactly what + ``t3api:cache:invalidate-expired`` needs and keeps using unmodified (see + "Scheduler command" below) - that command can only ever detect that *a + table* crossed a ``starttime``/``endtime`` threshold, never which + specific uids did, so it has no more precise tag to flush by. + +.. warning:: + Automatic tags only see entities that are *serialized into* the response - + a virtual property or custom serializer handler that returns the related + entity itself (typed so JMS serializes it as a nested object) is tagged + the same as any other relation. Only a virtual property or handler that + returns a **computed value derived from** a related record - e.g. + ``authorName`` built from a related author record, rather than the author + itself - keeps that record outside the serialized object graph entirely: + an invisible dependency, since editing that related record will NOT + invalidate the cached response. Declare such dependencies explicitly on + the operation, via ``tags`` (e.g. the related table name), ``tagExpressions`` + (e.g. a tag that does not depend on which entity ended up in the response) + or ``memberTagExpressions`` (e.g. a per-record tag built from the + serialized entity's relation, such as the ``author_`` example + below), see :ref:`response-cache-declarative-tags`. + +Invalidation is granular and driven by what could actually be stale: + +- A plain **update** to an already-persisted record flushes only that + record's own ``
_`` tag. Every cached collection that already + contains the record was tagged with that same uid when it was serialized + (see "Cache key" above), so it still refreshes correctly - a + ``
--collection`` flush would otherwise also evict unrelated + collections that never contained the record at all. +- An **update** that changes visibility or placement - any of the table's + TCA ``enablecolumns`` fields (e.g. ``disabled``, ``starttime``, + ``endtime``, ``fe_group``), its ``pid``, or its manual sorting field - is + treated like create/move/undelete below and flushes both the + ``
--collection`` tag and the record's own ``
_`` tag + instead, since such a change (e.g. un-hiding a record) can move it into a + collection that did not contain it before, and can also make the + record's own previously-cached responses (e.g. its item response) stale. +- **Create**, **move** and **undelete** flush the same + ``
--collection`` tag and the record's own ``
_`` tag, + since any of them can change which collections the record belongs to - a + cached collection that never contained the record carries no tag that a + uid-only flush could match. The record's own uid tag was never attached + to any cached entry before a create, so including it here is harmless, + not incorrect. +- **Delete** also flushes ``
--collection`` and the record's own + ``
_`` tag: the uid tag is implied dead along with the record, + and ``
--collection`` already covers every collection that contained + it. + +This is driven by the TYPO3 DataHandler hooks and by three granular Extbase +persistence events - entity added, updated and removed - each of which only +fires after a real database write, so walking the persistence graph without +any actual change does not trigger a needless flush. + +Both paths dispatch the public PSR-14 events +``SourceBroker\T3api\Event\RecordUpdatedEvent`` and +``SourceBroker\T3api\Event\RecordDeletedEvent``, which double as the API for +invalidating the cache manually - e.g. after a write that bypasses +DataHandler and Extbase persistence (see "Limitations" below). Dispatch +``RecordUpdatedEvent`` with ``changesCollectionMembership`` set to ``true`` +when the change may alter which collections the record belongs to (mirrors +create/move/undelete above); leave it ``false`` (the default) for a plain +field update to an already-persisted record. + +.. _response-cache-declarative-tags: + +Declarative extra tags +======================== + +The automatic invalidation above covers records read or written through +DataHandler or Extbase persistence. The ``cache`` block's ``tags`` key +covers a response whose staleness is driven by *another* table. It cascades +per operation exactly like every other ``cache`` key (see "Per-operation +override" above). + +``tags`` - extra literal tags added to a *stored* entry, on top of the +automatic content tags and the resource table seed. Plain strings only - a +tag whose value depends on the matched route parameters, the current user, +or anything else that is not the same for every request belongs in +``tagExpressions`` instead, see :ref:`response-cache-tag-expressions` below. +Typical use for a literal ``tags`` entry: a DTO endpoint whose data is +actually sourced from other tables - reusing those tables' tags lets the +existing DataHandler/Extbase invalidation for them invalidate this endpoint +too, at zero extra invalidation code: + +.. code-block:: php + + /** + * @ApiResource( + * attributes={ + * "cache"={ + * "tags"={"pages", "tt_content"} + * } + * } + * ) + */ + class StaticPage + { + } + +A ``StaticPage`` GET response built from ``pages``/``tt_content`` data has no +table of its own for the automatic resource-table seed (see "Tags and +invalidation" above) to tag it with - declaring ``tags`` here makes the +existing ``pages``/``tt_content`` DataHandler flush reach it anyway. + +.. _response-cache-tag-expressions: + +Tag expressions +------------------ + +``tags`` above covers a *fixed* relationship: a literal tag name, the same +regardless of who is asking or which route parameters matched. Three +expression-based sources cover everything dynamic instead: a tag whose value +depends on the matched route parameters (via ``route``), the current user, a +request header, a TYPO3 Context aspect, the top-level result entity/entities +(via ``object``), or anything else visible to a Symfony expression: + +- ``cache.tagExpressions`` - evaluated once per stored response, before the + operation handler's result is known - so it never has ``object``, and is + evaluated even for an empty collection. The right place for a tag that does + not depend on which entities ended up in the response, e.g. the current + user (see the ``fe_user_`` example below). +- ``cache.memberTagExpressions`` - evaluated once per *top-level result + entity* instead of once per response: once for an item GET's single result + entity, once per collection GET member (the resulting tags unioned and + deduplicated across all members), zero times for an empty collection. The + only one of the three with ``object``, bound to that one entity. The right + place for a tag that *does* depend on which entities ended up in the + response, e.g. each member's related author (see the ``author_`` + example below) - something a single per-response ``tagExpressions`` + evaluation cannot express for a collection. +- ``cacheInvalidation.tagExpressions`` - evaluated once after a successful + write, with ``object`` bound to the written entity (``null`` on a + ``DELETE``, or when a custom handler returns nothing, see "DELETE and other + object-less writes" below). + +All three are evaluated by the same expression engine and variable set as +``readCondition``/``writeCondition``/``identifierExpressions`` (see +:ref:`response-cache-conditions`), plus ``object`` where noted above - see +"Availability of ``object``" below for the full picture across every response +cache expression on this page. + +The canonical route-driven case is a write handler that bypasses +DataHandler/Extbase persistence and must flush a tag built from a matched +route parameter (see :ref:`response-cache-invalidation` below for the full +worked example): + +.. code-block:: php + + /** + * @ApiResource( + * itemOperations={ + * "toggleFavorite"={ + * "method"="POST", + * "path"="/recipes/{recipeId}/favorite", + * "attributes"={ + * "cacheInvalidation"={ + * "tagExpressions"={"'tx_myext_domain_model_recipe_' ~ route['recipeId']"} + * } + * } + * } + * } + * ) + */ + +``route['recipeId']`` reads the matched ``{recipeId}`` route parameter +directly - the same ``route`` variable available to every other cache +expression (see :ref:`response-cache-conditions`). + +The pair below tags every cached ``GET`` response with the current user, and +flushes exactly that user's entries once they write something - each cached +response stays scoped to the visitor who produced it, and a write only ever +invalidates its own author's cache, never anyone else's: + +.. code-block:: php + + /** + * @ApiResource( + * attributes={ + * "cache"={ + * "tagExpressions"={ + * "user.isLoggedIn() ? 'fe_user_' ~ user.getUid() : ''" + * } + * }, + * "cacheInvalidation"={ + * "tagExpressions"={"'fe_user_' ~ user.getUid()"} + * } + * } + * ) + */ + +``user`` here is the same project-provided expression variable used in +"Examples" above (see :ref:`response-cache-conditions`), not a built-in one. +An anonymous visitor's ``GET`` response evaluates the ``cache`` expression to +an empty string - the documented idiom for "no tag from this expression": +skipped silently rather than added as a literal empty tag - so anonymous +traffic carries only the usual automatic content tags. A logged-in visitor's +response additionally carries ``fe_user_``; once that same user performs +a write (any non-``GET`` operation on the resource), the +``cacheInvalidation`` expression flushes exactly that tag - every cached +response the user has ever produced, and nothing belonging to anyone else. + +``memberTagExpressions`` combines with ``tagExpressions`` on the same +``cache`` block, each covering what the other cannot. Extending the example +above, a ``Book`` collection also tags each stored entry with every embedded +book's author - something ``tagExpressions``, evaluated once for the whole +response, has no way to express for more than one book at a time: + +.. code-block:: php + + /** + * @ApiResource( + * attributes={ + * "cache"={ + * "tagExpressions"={"'fe_user_' ~ user.getUid()"}, + * "memberTagExpressions"={"'author_' ~ object.getAuthor().getUid()"} + * } + * } + * ) + */ + +``tagExpressions`` still evaluates once per response, exactly as above - +``user`` does not change depending on which book is being looked at. +``memberTagExpressions`` evaluates once per book in the collection, each time +with ``object`` bound to that one ``Book``: a two-book response ends up +tagged with (among others) both ``author_1`` and ``author_2``. Editing either +embedded author now invalidates exactly the collections that embedded them - +the same granularity the automatic nested-relation tagging already gives +editing the *book* record itself (see "Tags and invalidation" above), now +available for a *derived* per-member tag too. + +.. note:: + An empty collection has no member to evaluate ``memberTagExpressions`` + against - zero evaluations, zero tags from this source, same as if the + setting were not declared at all. A tag that does not depend on which + entities the response contains - including a user/request-scoped one like + the ``fe_user_`` example above, which must still apply to an empty + response - belongs in ``tagExpressions`` instead, which is always + evaluated exactly once regardless of how many entities (zero or more) end + up in the response. + +The two evaluated-once-per-response sides fail differently, deliberately. +``cache.tagExpressions`` is evaluated at store time and is fail-closed: an +expression that cannot be evaluated, or that produces a value that is not a +valid cache tag, means the would-be entry's invalidation contract cannot be +guaranteed, so the entry is not stored at all (logged as an error) - the +response is still returned to the caller, only the cache write is skipped. +``cache.memberTagExpressions`` follows the exact same fail-closed policy, but +per member: a single member's expression failure is enough to withhold the +*entire* entry, on the same reasoning - a partial per-member tag set could +not guarantee the whole entry's invalidation contract either. +``cacheInvalidation.tagExpressions`` is evaluated after a successful write +and is fail-soft: the write already succeeded and must not break because of +a caching side effect, so a failing expression there merely skips that one +tag (logged as a warning) while every other declared tag - static or +expression-derived - still flushes. + +DELETE and other object-less writes +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The ``object`` variable ``cacheInvalidation.tagExpressions`` sees is ``null`` +whenever the write operation has no entity to offer - a ``DELETE`` (the +object was just removed), or a custom operation handler that returns +nothing. Referencing a property or method on a ``null`` ``object`` throws, +which - being on the fail-soft side - only skips that one tag rather than +the whole flush, but a tag that is supposed to fire on every write +(create/update *and* delete) needs the null-safe idiom instead: + +.. code-block:: php + + /** + * @ApiResource( + * itemOperations={ + * "delete"={ + * "attributes"={ + * "cacheInvalidation"={ + * "tagExpressions"={ + * "object != null ? 'tx_myext_domain_model_recipe_' ~ object.getUid() : ''" + * } + * } + * } + * } + * } + * ) + */ + +A ``PUT``/``PATCH``/``POST`` on this same operation set (sharing the +resource-level ``cacheInvalidation`` block, or declaring the same expression +on their own) evaluates the ``object != null`` branch and flushes the tag as +usual; the ``DELETE`` above evaluates the ``: ''`` branch - the documented +"no tag from this expression" idiom - rather than erroring. For a tag that +does not need the entity at all (e.g. the ``route['recipeId']`` example +above), this is not a concern - it works identically for every HTTP method. + +.. _response-cache-object-availability: + +Availability of ``object`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +``object`` is deliberately not part of the shared variable set every +response cache expression sees (see :ref:`response-cache-conditions`) - most +of the surface below runs *before* the operation handler produces (or, on a +cache hit, never produces) any entity at all. The two sources that do +introduce it bind it to different things, at different points in the +request lifecycle: + +.. list-table:: + :header-rows: 1 + :widths: 30 70 + + * - Evaluation + - ``object`` + * - ``readCondition``, ``identifierExpressions`` + - never (pre-execution; a cache HIT never has an object at all) + * - ``writeCondition`` + - never (per-response; kept object-free by design) + * - ``cache.tagExpressions`` + - never (per-response; evaluated once, including for an empty + collection) + * - ``cache.memberTagExpressions`` + - the top-level result entity - item GET: the single result entity (one + evaluation); collection GET: each collection member (one evaluation + per member, results unioned and deduplicated); empty collection: zero + evaluations + * - ``cacheInvalidation.tagExpressions`` + - the persisted entity of the write operation; ``null`` on a ``DELETE`` + (or when a custom handler returns nothing), see "DELETE and other + object-less writes" above + +Referencing ``object`` anywhere it is "never" available is an ordinary +expression evaluation error, handled by that side's usual failure policy - +fail-closed for ``readCondition``/``writeCondition``/``identifierExpressions``/ +``cache.tagExpressions`` (see :ref:`response-cache-conditions` and above), +fail-soft for ``cacheInvalidation.tagExpressions`` (see above). + +.. _response-cache-invalidation: + +Write-triggered invalidation +============================== + +A write that bypasses DataHandler/Extbase persistence entirely invalidates +nothing automatically (see "Limitations" below). The ``cacheInvalidation`` +attributes block - a sibling of ``cache``, not one of its keys - covers this +case: it flushes declared tags once a non-``GET`` operation completes +successfully. + +.. code-block:: php + + use SourceBroker\T3api\Annotation\ApiResource; + + /** + * @ApiResource( + * itemOperations={ + * "toggleFavorite"={ + * "method"="POST", + * "path"="/recipes/{recipeId}/favorite", + * "attributes"={ + * "cacheInvalidation"={ + * "tagExpressions"={"'tx_myext_domain_model_recipe_' ~ route['recipeId']"} + * } + * } + * } + * } + * ) + */ + class Recipe extends \TYPO3\CMS\Extbase\DomainObject\AbstractEntity + { + } + +The favorites-toggle handler writes with raw SQL (or any other means +bypassing DataHandler/Extbase persistence); once it returns successfully, +``cacheInvalidation`` flushes exactly the affected recipe's tag, built from +``route['recipeId']`` - the matched ``{recipeId}`` route parameter (see +:ref:`response-cache-tag-expressions` above). + +``cacheInvalidation`` accepts two keys: + +- ``tags`` - literal tags flushed after the operation completes successfully + (default: none). Plain strings only, see :ref:`response-cache-declarative-tags` + above. +- ``tagExpressions`` - Symfony expressions whose non-empty string results are + each flushed alongside ``tags`` (default: none). See + :ref:`response-cache-tag-expressions`. + +Like ``cache``, ``cacheInvalidation`` cascades from resource level to every +operation (see "Per-operation override" above): a resource-level +``cacheInvalidation`` block flushes its ``tags``/``tagExpressions`` after +*any* write operation on the resource succeeds, unless a specific operation +overrides them with its own block. + +It fires only after a **successfully completed** non-``GET`` operation - a +null response (e.g. a DELETE) or a 2xx status both count as success; an +exception raised by the operation propagates without flushing anything, +since a failed write must not invalidate a cache entry that never went +stale. A ``GET`` operation never flushes, even when a resource-level +``cacheInvalidation`` block cascades onto it - such a block is meant for the +resource's write operations, and reading never invalidates anything. + +.. _response-cache-invalidation-fail-fast: + +Fail-fast validation +---------------------- + +``cache`` and ``cacheInvalidation`` are mutually exclusive per operation, by +HTTP method. Declaring ``cache`` explicitly on a non-``GET`` operation raises +an ``InvalidArgumentException`` (naming the operation and entity) as soon as +routes are built for the resource, pointing to ``cacheInvalidation`` for +write-triggered invalidation instead. Declaring ``cacheInvalidation`` +explicitly on a ``GET`` operation raises the mirror exception, pointing to +``cache`` for read caching instead. Both checks only apply to an *explicit* +per-operation ``attributes`` block - a block inherited from the resource +level (e.g. a resource-level ``cacheInvalidation`` reaching a ``GET`` +operation) is a legitimate cascade and never throws. + +Tag validation +=============== + +Every ``tags`` entry (both ``cache`` and ``cacheInvalidation``) and every +non-empty ``tagExpressions``/``memberTagExpressions`` result is validated +against the TYPO3 Caching Framework's allowed tag charset +(``FrontendInterface::PATTERN_TAG`` - letters, digits, ``_``, ``%``, ``-``, +``&``, 1 to 250 characters) before it ever reaches the cache backend. A +literal ``tags`` entry that fails this check is skipped with a logged warning +instead of failing the request - see :ref:`response-cache-tag-expressions` +for how a failing ``tagExpressions``/``memberTagExpressions`` result is +handled (fail-closed on the ``cache`` side, fail-soft on +``cacheInvalidation``). + +Scheduler command +================== + +For records that appear or disappear over time via ``starttime``/``endtime``, +schedule the CLI command: + +.. code-block:: bash + + vendor/bin/typo3 t3api:cache:invalidate-expired + +On each run it checks every TCA table that declares a ``starttime`` or +``endtime`` enable-field - not just tables backing a cacheable resource, +since a cached response can embed related records from other tables (for +example an author nested in a book response) that need the same check - +for whether a start or end time passed since the command's last run, and +flushes that table's ``
`` tag if so. The per-table last-run +timestamp is only persisted to the TYPO3 Registry once that table's check +(and the flush it may trigger) has completed without error, so a failure +does not silently lose an invalidation window - the next run simply +re-checks it. A table whose TCA declares neither the ``starttime`` nor the +``endtime`` enable-field is skipped silently: never flushed, no output, no +error. + +TYPO3 workspaces are not supported by the response cache invalidation, +neither by this command nor by the record-change event listeners. + +.. _response-cache-hit-semantics: + +Cache hit semantics +==================== + +A cache hit is served without running the operation handler, database +queries, serialization, or any PSR-14 operation events (e.g. +``AfterProcessOperationEvent``). The ``security`` check and the +``readCondition`` expression are the exception: both run on every request, +hit or miss, before the cached entry is looked up - see "Access control" and +"Conditions" above. + +What a hit actually saves scales with what serving the request would have +cost otherwise: skipping the database queries and serialization of a small, +simple resource buys little, while a large or deeply nested one benefits far +more. Either way, the request still pays the full TYPO3 bootstrap (site and +language resolution, the DI container, TCA) - the response cache is reached +from inside frontend request handling, not in front of it, so a hit cannot +shortcut that fixed baseline. + +.. _response-cache-verifying: + +Verifying and debugging +========================= + +**Debug headers** - in a development context (``TYPO3_CONTEXT=Development`` +or any of its subcontexts, e.g. ``Development/Local`` - see +``Environment::getContext()->isDevelopment()``), every t3api response +carries ``X-T3api-Cache*`` headers describing what this exact request did: + +- ``X-T3api-Cache: hit|miss|bypass`` - ``hit`` when the response was served + from a stored entry, ``miss`` when the lookup ran but found nothing, + ``bypass`` when ``readCondition`` evaluated falsy and the lookup never + ran. Present only on a cacheable request (a ``GET`` whose cache entry + identifier could be built) - a request with nothing to cache in the first + place (a non-``GET`` operation, a disabled ``cache`` block, a + secured-filter bypass) carries none of it. +- ``X-T3api-Cache-Identifier`` - the cache entry identifier: present on a + ``hit``, and on a ``miss``/``bypass`` whenever the response was actually + stored. +- ``X-T3api-Cache-Tags`` - the full stored tag set (content tags, the + resource table seed, declarative ``tags``, ``tagExpressions`` and + ``memberTagExpressions``, comma-separated), present only when the + response was actually stored. +- ``X-T3api-Cache-Flushed-Tags`` - the tags a ``cacheInvalidation`` flush + actually flushed, present whenever that flush ran with at least one tag. + +These headers are hard off outside a development context - they disclose +cache internals (entry identifiers, tag names) that must never reach a +production response. A ``bypass`` still allows storing: the refresh/warm-up +pattern (see "Examples" above) deliberately skips the lookup while still +writing a fresh entry, so seeing ``X-T3api-Cache: bypass`` alongside a +present ``X-T3api-Cache-Tags`` header is exactly that happening, not a bug. + +**Inspecting the cache** - with the default database backend (see "Storage +backend" below), every stored entry is a row in the ``cache_t3api_response`` +table: ``identifier`` is the md5 hash described in "Cache key" above, +``content`` the stored response body, and ``expires`` the unix timestamp it +expires at. Its tags live in ``cache_t3api_response_tags``, one row per +``(identifier, tag)`` pair - ``SELECT tag FROM cache_t3api_response_tags +WHERE identifier = ''`` lists everything that invalidates a given +entry. No row for the identifier you expect means the request was never +stored in the first place (see the checklist below) or the entry already +expired/was flushed; both look identical from a single lookup, so to tell +them apart, request the same URL twice in a row - if neither attempt leaves +a row, the response was never being stored at all. + +**Testing from the command line** - a plain ``curl`` request exercises the +same path a real client does, custom authentication header included: + +.. code-block:: bash + + curl -i -H "Authorization: Bearer " "https://example.com/_api/books" + +Run it twice: the first response is a MISS (assembled fresh), the second - +within ``lifetime``, with the same declared pagination/filter parameters and +the same result for every declared ``identifierExpressions`` entry - is a +HIT served from the stored entry. An undeclared query parameter (e.g. +``?debugMe=1`` when no such filter exists) neither creates a new entry nor +bypasses the existing one - see "Cache key" above. + +**Why is my endpoint not cached?** - work through this list: + +- No ``cache`` block resolves to enabled for this exact operation - the + per-operation override wins in either direction (see "Per-operation + override" above), so check both the resource and the specific operation. +- The request is not ``GET`` - only ``GET`` operations are ever read from, + or written to, the response cache. +- The response status is not exactly ``200`` - a response with any other + status (e.g. a redirect or an empty ``204``) is never stored, even for an + otherwise-cacheable operation. +- ``readCondition``/``writeCondition`` evaluates falsy, or fails to + evaluate - both fail closed. Look for ``t3api response cache condition + evaluation failed`` in the logs (see below). +- Any ``identifierExpressions`` entry fails to evaluate - unlike a failed + condition, this disables caching for the request entirely (no read, no + write). Look for ``t3api response cache identifier expression evaluation + failed``. +- Any ``cache.tagExpressions``/``cache.memberTagExpressions`` entry fails to + evaluate, or produces an invalid tag - fail-closed, same as + ``identifierExpressions``: the entry is not stored at all, even when only + one collection member's ``memberTagExpressions`` evaluation was the one + that failed (see :ref:`response-cache-tag-expressions`). Look for ``t3api + response cache tag expression evaluation failed`` / ``... produced an + invalid tag`` (both logged as errors). +- The request targets a filter whose strategy declares a non-empty + ``condition`` - such a request bypasses the cache entirely, since that + check is not re-evaluated on the cache path (see "Access control" above). +- On an item operation, the ``security`` expression references ``object`` + and the entity cannot be loaded (e.g. a non-existent id) - the request + falls through to the normal, uncached handler path (see "Access control" + above). +- The resource has no ``AbstractDomainObject`` entity (a synthetic/DTO + resource, typically served by a custom operation handler) - it is cached + and expires by ``lifetime`` like any other resource, but only invalidates + automatically for the entities it happens to serialize. Add ``cache``'s + ``tags`` to tie its ``GET`` response to the tables it actually reads from, + and ``cacheInvalidation``'s ``tags`` to flush it after a write that + bypasses DataHandler/Extbase persistence (see "Tags and invalidation", + :ref:`response-cache-declarative-tags` and + :ref:`response-cache-invalidation` above). +- ``cache`` was declared explicitly on a non-``GET`` operation, or + ``cacheInvalidation`` explicitly on a ``GET`` operation - this raises an + ``InvalidArgumentException`` as soon as routes are built for the resource, + it is never silently ignored (see + :ref:`response-cache-invalidation-fail-fast` above). + +**Where to look in the logs** - every response-cache failure is logged via +TYPO3's standard PSR-3 logging at warning level or above, which by default +reaches TYPO3's file log writer (``var/log/typo3_.log`` in a standard +installation) alongside the rest of the site's log records. Search for: + +- ``t3api response cache condition evaluation failed`` - a ``readCondition``/ + ``writeCondition`` error. +- ``t3api response cache identifier expression evaluation failed`` - an + ``identifierExpressions`` entry error. +- ``t3api response cache read failed`` / ``... write failed`` / ``... flush + failed`` - the cache backend itself errored. +- ``t3api response cache tag is invalid`` - a ``cache``/``cacheInvalidation`` + ``tags`` entry failed the tag charset check and was skipped (see "Tag + validation" above). +- ``t3api response cache tag expression evaluation failed`` / ``... produced + an invalid tag`` - a ``tagExpressions``/``memberTagExpressions`` entry + error, logged as an error (entry not stored) on the ``cache`` side and a + warning (tag skipped, entry still flushed) on the ``cacheInvalidation`` + side, see :ref:`response-cache-tag-expressions`. +- ``t3api response cache memberTagExpressions skipped a result entry that is + not an AbstractDomainObject`` - a collection member (or an item GET's + result) was not an Extbase entity, so ``memberTagExpressions`` was never + evaluated against it - logged as a warning, does not block the store (see + :ref:`response-cache-tag-expressions`). +- ``Response cache security check for operation`` - an item operation's + ``security`` expression needed the loaded entity (see "Access control" + above). + +**Interaction with other caching layers** - t3api requests deliberately +never populate, and are never served from, TYPO3's own page cache: t3api +randomises the page cache identifier for every request it handles, so these +responses stay out of it. The response cache described in this chapter is +therefore the only caching layer t3api adds inside TYPO3. + +T3api does not set any HTTP caching headers (``Cache-Control``, ``Expires``, +``ETag``) on its responses - the development-only ``X-T3api-Cache*`` headers +above are diagnostics, not caching directives, and carry no instruction any +proxy, CDN or browser would act on. A reverse proxy or CDN placed in front +of TYPO3 is unaware of this feature entirely: it neither shares its +invalidation (a record change purges only the response cache described +here, never an external cache) nor reacts to anything t3api emits. Bypass +such a layer for the API path, or drive its invalidation independently, if +one sits in front of your installation. + +Limitations +=========== + +Three limitations remain: + +- A plain update that makes a record newly match a collection's filters + (e.g. a field change that satisfies a filter condition it previously + failed) does not refresh a collection that did not already contain the + record - the update flush above only reaches the record's own + ``
_`` tag, which such a collection was never tagged with. It + simply expires with the entry's ``lifetime``. +- Writes that bypass DataHandler and Extbase persistence (raw SQL, direct + imports) trigger no invalidation. Flush the ``t3api_response`` cache + manually, or dispatch ``RecordUpdatedEvent``/``RecordDeletedEvent`` + yourself for the affected records (see "Tags and invalidation" above). +- The Extbase persistence path cannot detect *which* fields changed on an + update, unlike the DataHandler hook - it always dispatches + ``RecordUpdatedEvent`` with ``changesCollectionMembership`` ``false``. An + Extbase update that un-hides a record (or otherwise changes its + visibility or placement) therefore only refreshes responses that already + contained the record - a collection that did not contain it before stays + stale until ``lifetime`` expires. The same change made through the TYPO3 + backend does not have this gap (see "Tags and invalidation" above). + +Storage backend +================ + +The cache is registered as ``t3api_response`` with TYPO3 defaults (database +backend). Swap the backend in ``config/system/settings.php``, e.g. to Redis: + +.. code-block:: php + + $GLOBALS['TYPO3_CONF_VARS']['SYS']['caching']['cacheConfigurations']['t3api_response']['backend'] + = \TYPO3\CMS\Core\Cache\Backend\RedisBackend::class; + +The same defaults place the cache in the ``all`` cache group only: the +backend's "Flush frontend caches" action (which flushes the ``pages`` +group) does not touch it - tag-based invalidation keeps entries fresh +without it - while "Flush all caches" always clears it (see "Tags and +invalidation" above). T3api only provides defaults and never overrides +an existing ``t3api_response`` configuration, so a project that wants the +frontend flush to cover API responses too can opt in the same way: + +.. code-block:: php + + $GLOBALS['TYPO3_CONF_VARS']['SYS']['caching']['cacheConfigurations']['t3api_response']['groups'] + = ['all', 'pages']; + +Every distinct combination of declared filter and pagination parameters +produces its own cache entry (see "Cache key" above), so a heavily +filterable/orderable resource has an effectively unbounded key space. Size +the backend - or configure its eviction policy - accordingly, and keep +``lifetime`` tight enough to bound growth. + +To disable the response cache outright - typically for local development, +where stale output while iterating on a resource is more of a nuisance than +a saving - swap the backend to TYPO3's ``NullBackend`` the same way: + +.. code-block:: php + + $GLOBALS['TYPO3_CONF_VARS']['SYS']['caching']['cacheConfigurations']['t3api_response']['backend'] + = \TYPO3\CMS\Core\Cache\Backend\NullBackend::class; + +Every store becomes a no-op and every read a guaranteed miss, so the +operation handler always runs. diff --git a/README.rst b/README.rst index 063be50..65182d8 100644 --- a/README.rst +++ b/README.rst @@ -17,6 +17,7 @@ Features - Configuration with classes, properties and methods annotations. - Build-in filters: boolean, numeric, order, range and text (partial, match against and exact strategies). - Build-in pagination. +- Opt-in response caching with automatic tag-based invalidation. - Support for typolinks. - Support for image processing. - Support for file uploads (FAL). diff --git a/Tests/Functional/Command/InvalidateExpiredResponseCacheCommandTest.php b/Tests/Functional/Command/InvalidateExpiredResponseCacheCommandTest.php new file mode 100644 index 0000000..3f17e18 --- /dev/null +++ b/Tests/Functional/Command/InvalidateExpiredResponseCacheCommandTest.php @@ -0,0 +1,144 @@ +getConnectionPool()->getConnectionForTable('tt_content')->insert('tt_content', [ + 'pid' => 1, + 'starttime' => $now - 60, + ]); + + $command = $this->createCommand(); + + self::assertTrue($this->callHasTimeBasedVisibilityChanged($command, 'tt_content', 0, $now)); + } + + #[Test] + public function returnsFalseForWindowAfterTheChange(): void + { + $now = time(); + $this->getConnectionPool()->getConnectionForTable('tt_content')->insert('tt_content', [ + 'pid' => 1, + 'starttime' => $now - 60, + ]); + + $command = $this->createCommand(); + + self::assertFalse($this->callHasTimeBasedVisibilityChanged($command, 'tt_content', $now, $now + 120)); + } + + #[Test] + public function detectsEndtimeChangeWithinExplicitWindow(): void + { + $now = time(); + $this->getConnectionPool()->getConnectionForTable('tt_content')->insert('tt_content', [ + 'pid' => 1, + 'endtime' => $now - 60, + ]); + + $command = $this->createCommand(); + + self::assertTrue($this->callHasTimeBasedVisibilityChanged($command, 'tt_content', 0, $now)); + } + + #[Test] + public function returnsFalseForTablesWithoutEnableFields(): void + { + self::assertFalse($this->callHasTimeBasedVisibilityChanged($this->createCommand(), 'be_groups', 0, time())); + } + + /** + * Documents the silent-skip path a table without any TCA entry falls through: + * `getStartTimeAndEndTimeFields()` resolves both enable-fields to null via `??` and + * `hasTimeBasedVisibilityChanged()` returns false before ever building a query. Using a + * table name absent from the test database schema too proves this - if a query were + * attempted, it would fail with a DBAL exception instead of this assertion. + */ + #[Test] + public function returnsFalseWithoutTouchingDatabaseForTableWithoutAnyTcaEntry(): void + { + self::assertFalse( + $this->callHasTimeBasedVisibilityChanged( + $this->createCommand(), + 'tx_t3api_test_table_without_tca', + 0, + time() + ) + ); + } + + /** + * The scheduler command scans every time-restricted TCA table, not just tables backing an + * `@ApiResource` - a cached response can embed related entities from other tables (e.g. an + * author nested in a book response), so this table must be picked up purely because it + * declares a `starttime` enablecolumn, regardless of any `@ApiResource` annotation. + */ + #[Test] + public function getTimeRestrictedTablesIncludesTableWithStarttimeAndNoApiResource(): void + { + $table = 'tx_t3api_test_table_without_api_resource'; + $GLOBALS['TCA'][$table] = [ + 'ctrl' => [ + 'enablecolumns' => [ + 'starttime' => 'starttime', + ], + ], + ]; + + try { + $tables = $this->callGetTimeRestrictedTables($this->createCommand()); + } finally { + unset($GLOBALS['TCA'][$table]); + } + + self::assertContains($table, $tables); + } + + private function createCommand(): InvalidateExpiredResponseCacheCommand + { + return new InvalidateExpiredResponseCacheCommand( + $this->get(ConnectionPool::class), + $this->get(Registry::class), + new NullFrontend('t3api_response') + ); + } + + private function callHasTimeBasedVisibilityChanged( + InvalidateExpiredResponseCacheCommand $command, + string $table, + int $lastExecutionTimestamp, + int $executionTimestamp + ): bool { + $method = (new \ReflectionClass($command))->getMethod('hasTimeBasedVisibilityChanged'); + $method->setAccessible(true); + + return $method->invoke($command, $table, $lastExecutionTimestamp, $executionTimestamp); + } + + /** + * @return string[] + */ + private function callGetTimeRestrictedTables(InvalidateExpiredResponseCacheCommand $command): array + { + $method = (new \ReflectionClass($command))->getMethod('getTimeRestrictedTables'); + $method->setAccessible(true); + + return $method->invoke($command); + } +} diff --git a/Tests/Functional/Dispatcher/ResponseCacheDispatcherTest.php b/Tests/Functional/Dispatcher/ResponseCacheDispatcherTest.php new file mode 100644 index 0000000..efda7e0 --- /dev/null +++ b/Tests/Functional/Dispatcher/ResponseCacheDispatcherTest.php @@ -0,0 +1,482 @@ + HIT (stale output served after a direct SQL change) -> + * invalidation via the real PSR-14 event dispatcher -> MISS again with fresh data. + * + * Both scenarios live in this single test class deliberately: `SiteService::getCurrent()` + * and `RouteService`'s route-enhancer lookup memoize their result in function-local static + * variables for the lifetime of the PHP process, so every scenario that depends on site/route + * resolution has to run against the one site configuration written in setUp(). + */ +class ResponseCacheDispatcherTest extends FunctionalTestCase +{ + protected array $testExtensionsToLoad = [ + 'typo3conf/ext/t3api', + 'typo3conf/ext/t3api/Tests/Functional/Fixtures/Extensions/t3api_response_cache_test', + ]; + + private const TABLE = 'tx_responsecachetest_domain_model_book'; + + private const AUTHOR_TABLE = 'tx_responsecachetest_domain_model_author'; + + private const SITE_IDENTIFIER = 'functional-test'; + + protected function setUp(): void + { + parent::setUp(); + $this->importCSVDataSet(__DIR__ . '/../Fixtures/books.csv'); + $this->importCSVDataSet(__DIR__ . '/../Fixtures/authors.csv'); + $this->writeSiteConfiguration(); + + // SiteService::getCurrent() falls back to TYPO3\CMS\Core\Http\ServerRequestFactory::fromGlobals(), + // which reads the real PHP superglobals (not $GLOBALS['TYPO3_REQUEST']) and refuses to build a + // request URL on CLI unless $_SERVER carries one - fake it so site resolution succeeds. + $_SERVER['HTTP_HOST'] = 'example.com'; + $_SERVER['REQUEST_URI'] = '/_api/books'; + $_SERVER['SCRIPT_NAME'] = '/index.php'; + + $site = $this->get(SiteFinder::class)->getSiteByIdentifier(self::SITE_IDENTIFIER); + + // Extbase's ConfigurationManager resolves this request through $GLOBALS['TYPO3_REQUEST'] to build + // the persistence QuerySettings used by CommonRepository. Marking it FE would route through + // FrontendConfigurationManager, which hard-requires a `frontend.typoscript` request attribute that + // is normally populated by the frontend middleware stack we deliberately bypass here. Marking it BE + // routes through BackendConfigurationManager instead, which is built to gracefully compute a + // (page-less, in our case) TypoScript setup without a real page tree - this is the same mechanism + // every Extbase backend module relies on. `applicationType` itself is not read anywhere in t3api's + // own code, so this only affects Extbase's internal TypoScript bootstrapping, not dispatch behaviour. + $GLOBALS['TYPO3_REQUEST'] = (new ServerRequest('https://example.com/_api/books', 'GET')) + ->withAttribute('site', $site) + ->withAttribute('language', $site->getDefaultLanguage()) + ->withAttribute('applicationType', SystemEnvironmentBuilder::REQUESTTYPE_BE); + } + + #[Test] + public function collectionResponseIsCachedAndInvalidatedByRecordUpdatedEvent(): void + { + // 1. MISS - fresh output contains the fixture title. + $firstOutput = $this->dispatchCollectionGet(); + self::assertStringContainsString('First book', $firstOutput); + + // 2. Change the row behind t3api's back - a cache HIT must still serve the stale output. + $this->updateBookTitle('Changed title'); + self::assertStringContainsString( + 'First book', + $this->dispatchCollectionGet(), + 'Second request must be served from cache and therefore still show the pre-update title.' + ); + + // 3. Dispatch the invalidation event - next request must be a MISS with fresh data. + $this->get(EventDispatcherInterface::class)->dispatch(new RecordUpdatedEvent(1, self::TABLE)); + self::assertStringContainsString( + 'Changed title', + $this->dispatchCollectionGet(), + 'After invalidation the cache must be bypassed and fresh data returned.' + ); + } + + #[Test] + public function emptyCollectionResponseIsCachedAndInvalidatedWhenFirstRecordIsCreated(): void + { + $this->deleteAllBooks(); + + // 1. MISS - fresh output is an empty collection, but the response cache now seeds the + // resource table tag before serialization, so this entry is invalidated by the table + // tag below even though serialization itself never touched a single `Book` instance. + $firstOutput = $this->dispatchCollectionGet(); + self::assertStringNotContainsString('First book', $firstOutput); + self::assertStringNotContainsString('Second book', $firstOutput); + + // 2. Insert a row behind t3api's back - a cache HIT must still serve the stale, empty output. + $newBookUid = $this->insertBook('Newly inserted book'); + $secondOutput = $this->dispatchCollectionGet(); + self::assertStringNotContainsString( + 'Newly inserted book', + $secondOutput, + 'Second request must be served from cache and therefore still show the pre-insert (empty) collection.' + ); + + // 3. Dispatch the invalidation event for the new record - next request must be a MISS with fresh data. + $this->get(EventDispatcherInterface::class)->dispatch( + new RecordUpdatedEvent($newBookUid, self::TABLE, changesCollectionMembership: true) + ); + self::assertStringContainsString( + 'Newly inserted book', + $this->dispatchCollectionGet(), + 'After invalidation the cache must be bypassed and the newly created record returned.' + ); + } + + #[Test] + public function collectionResponseWithDeclaredTagIsInvalidatedByDirectTagFlush(): void + { + // 1. MISS - fresh output contains the fixture title, stored under the operation's + // declared `"tags"={"functional_custom_tag"}` on top of the automatic content tags. + $firstOutput = $this->dispatchCollectionGet(); + self::assertStringContainsString('First book', $firstOutput); + + // 2. Change the row behind t3api's back - a cache HIT must still serve the stale output. + $this->updateBookTitle('Changed via declared tag'); + self::assertStringContainsString( + 'First book', + $this->dispatchCollectionGet(), + 'Second request must be served from cache before the declared tag is flushed.' + ); + + // 3. Flush the declared tag directly via the cache frontend - bypassing t3api's own + // automatic record-change invalidation entirely - to prove the tag was actually stored + // on the entry. The next request must be a MISS with fresh data. + $this->get(CacheManager::class)->getCache('t3api_response')->flushByTag('functional_custom_tag'); + self::assertStringContainsString( + 'Changed via declared tag', + $this->dispatchCollectionGet(), + 'After flushing the declared custom tag directly, the next request must be a MISS with fresh data.' + ); + } + + /** + * `Book`'s collection `GET` declares `"memberTagExpressions"={"'author_' ~ object.getAuthor().getUid()"}` + * (see the fixture) - evaluated once per collection member (`object` bound to that one `Book`), + * not once for the whole response like `tagExpressions`. Book 1 embeds Author A (uid 1), book 2 + * embeds Author B (uid 2, never touched here) - flushing `author_1` directly proves the tag was + * actually derived per member and reaches the stored entry, end to end through the real + * dispatcher/cache-frontend wiring (unlike `OperationResponseCacheTest`, which only asserts the + * wiring against a mocked `ResponseCacheService`). + */ + #[Test] + public function collectionResponseWithMemberTagExpressionIsInvalidatedByDirectAuthorTagFlush(): void + { + // 1. MISS - fresh output, stored under (among others) the `author_1`/`author_2` tags derived + // from `memberTagExpressions`, one evaluation per collection member. + $firstOutput = $this->dispatchCollectionGet(); + self::assertStringContainsString('First book', $firstOutput); + + // 2. Change the row behind t3api's back - a cache HIT must still serve the stale output. + $this->updateBookTitle('Changed via member tag expression'); + self::assertStringContainsString( + 'First book', + $this->dispatchCollectionGet(), + 'Second request must be served from cache before the member-derived tag is flushed.' + ); + + // 3. Flush the tag derived from book 1's author (Author A, uid 1) directly via the cache + // frontend - bypassing t3api's own automatic record-change invalidation entirely - to prove + // `memberTagExpressions` actually tagged the entry per member. The next request must be a + // MISS with fresh data. + $this->get(CacheManager::class)->getCache('t3api_response')->flushByTag('author_1'); + self::assertStringContainsString( + 'Changed via member tag expression', + $this->dispatchCollectionGet(), + 'After flushing the per-member tag derived from book 1\'s author, the next request must be a MISS with fresh data.' + ); + } + + #[Test] + public function requestFailingReadAndWriteConditionsBypassesCacheLookupAndStore(): void + { + $this->dispatchCollectionGet(); + $this->updateBookTitle('Uncached title'); + + self::assertStringContainsString( + 'Uncached title', + $this->dispatchCollectionGet('https://example.com/_api/books?nocache=1'), + 'A request failing the readCondition must be processed freshly instead of served from cache.' + ); + + // `nocache` is not a declared filter/pagination parameter, so this request maps to the SAME + // cache entry identifier as a plain GET - if the fresh output had been stored despite the + // failing writeCondition, the next plain GET would show the new title instead of the cached one. + self::assertStringContainsString( + 'First book', + $this->dispatchCollectionGet(), + 'The response of a request failing the writeCondition must not overwrite the cache entry.' + ); + } + + /** + * `Book::$author` is a plain `ManyToOne` relation to `Author`, which carries no `@ApiResource` + * of its own - it only ever appears nested inside a `Book` response. This proves the automatic + * per-record `
_` tagging in `CacheTagSubscriber` is driven purely by which + * `AbstractDomainObject` instances get serialized into the response, not by whether the related + * entity is itself a resource: editing "Author A" - embedded in the "First book" collection + * entry - invalidates the cached collection response even though nothing in its own right was + * ever requested through the API. + */ + #[Test] + public function collectionResponseIsInvalidatedByUpdateOfNestedAuthorRecord(): void + { + // 1. MISS - fresh output nests "Author A" inside the "First book" entry. + $firstOutput = $this->dispatchCollectionGet(); + self::assertStringContainsString('Author A', $firstOutput); + + // 2. Change the related author row behind t3api's back - a cache HIT must still serve the + // stale, nested author name. + $this->updateAuthorName('Author A', 'Changed author name'); + self::assertStringContainsString( + 'Author A', + $this->dispatchCollectionGet(), + 'Second request must be served from cache and therefore still show the pre-update author name.' + ); + + // 3. Dispatch the invalidation event for the author record - next request must be a MISS + // with the fresh, nested author name. + $this->get(EventDispatcherInterface::class)->dispatch(new RecordUpdatedEvent(1, self::AUTHOR_TABLE)); + self::assertStringContainsString( + 'Changed author name', + $this->dispatchCollectionGet(), + 'After invalidation the cache must be bypassed and the fresh, nested author name returned.' + ); + } + + /** + * Negative-case shape: book uid 1 embeds only "Author A" (author uid 1); book uid 2 embeds only + * "Author B" (author uid 2, never requested here). Flushing author B's own `
_` tag + * must NOT invalidate book 1's cached item response, since that response was never tagged with + * it - only flushing author A's tag, the one actually nested inside book 1's response, may. + * This proves nested tagging is precise per embedded record, not a blanket flush of the whole + * related table. + */ + #[Test] + public function itemResponseIsInvalidatedOnlyByItsOwnNestedAuthorNotByAnUnrelatedOne(): void + { + // 1. MISS - fresh output for book 1 nests "Author A". + $firstOutput = $this->dispatchBookItemGet(1); + self::assertStringContainsString('Author A', $firstOutput); + + // 2. Change author A's row behind t3api's back - a cache HIT must still serve the stale, + // nested author name. + $this->updateAuthorName('Author A', 'Changed author A name'); + self::assertStringContainsString( + 'Author A', + $this->dispatchBookItemGet(1), + 'Second request must be served from cache and therefore still show the pre-update author name.' + ); + + // 3. NEGATIVE - flush author B's tag (uid 2, embedded only in book 2, never requested here). + // Book 1's cached item response must remain untouched: still a HIT, still the stale name. + $this->get(EventDispatcherInterface::class)->dispatch(new RecordUpdatedEvent(2, self::AUTHOR_TABLE)); + self::assertStringContainsString( + 'Author A', + $this->dispatchBookItemGet(1), + 'Flushing an unrelated author\'s tag must not invalidate a response that never embedded that author.' + ); + + // 4. POSITIVE - flush author A's own tag (uid 1, actually embedded in book 1's response). + // The next request must be a MISS with the fresh, nested author name. + $this->get(EventDispatcherInterface::class)->dispatch(new RecordUpdatedEvent(1, self::AUTHOR_TABLE)); + self::assertStringContainsString( + 'Changed author A name', + $this->dispatchBookItemGet(1), + 'Flushing book 1\'s own nested author tag must invalidate its cached item response.' + ); + } + + /** + * Proves the cache-tag scoping fix: a membership-changing write's *automatic* flush target is + * `
--collection` plus the record's own `
_` (see `FlushCacheAfterSaveListener`), + * never the blanket `
` tag every response also carries - so an unrelated record's + * membership-changing update can no longer evict a still-correct cached item response on the + * same table, only the record's own update (via its own uid tag) or a plain update (via the + * unchanged, uid-only plain-update path) still can. + */ + #[Test] + public function itemResponseSurvivesAnUnrelatedMembershipChangeButIsEvictedByItsOwnUpdate(): void + { + // 1. MISS - fresh output for book 1. + $firstOutput = $this->dispatchBookItemGet(1); + self::assertStringContainsString('First book', $firstOutput); + + // 2. Change book 1's row behind t3api's back - a cache HIT must still serve the stale title. + $this->updateBookTitle('Changed title after unrelated update'); + self::assertStringContainsString( + 'First book', + $this->dispatchBookItemGet(1), + 'Second request must be served from cache and therefore still show the pre-update title.' + ); + + // 3. NEGATIVE - dispatch a membership-changing update for an UNRELATED book (uid 2). Book 1's + // cached item response must remain untouched: still a HIT, still the stale title. + $this->get(EventDispatcherInterface::class)->dispatch( + new RecordUpdatedEvent(2, self::TABLE, changesCollectionMembership: true) + ); + self::assertStringContainsString( + 'First book', + $this->dispatchBookItemGet(1), + 'A membership-changing update to an unrelated record must not evict this record\'s cached item response.' + ); + + // 4. POSITIVE - dispatch a membership-changing update for book 1 ITSELF. Its own + // `
_` tag must evict the cached item response (own-uid-tag correctness + // requirement), even though this response never carried the `
--collection` tag that + // step 3's unrelated update flushed instead. + $this->get(EventDispatcherInterface::class)->dispatch( + new RecordUpdatedEvent(1, self::TABLE, changesCollectionMembership: true) + ); + self::assertStringContainsString( + 'Changed title after unrelated update', + $this->dispatchBookItemGet(1), + 'A membership-changing update to the record itself must evict its own cached item response.' + ); + + // 5. Change book 1's row behind t3api's back again - a cache HIT must still serve the title + // from step 4, not the one written just now. + $this->updateBookTitle('Changed title after plain update'); + self::assertStringContainsString( + 'Changed title after unrelated update', + $this->dispatchBookItemGet(1), + 'Third request must be served from cache and therefore still show the previous, not the latest, title.' + ); + + // 6. REGRESSION GUARD - a plain (non-membership) update to book 1 must still evict its own + // cached item response, exactly as before this change (the unchanged, uid-only path). + $this->get(EventDispatcherInterface::class)->dispatch(new RecordUpdatedEvent(1, self::TABLE)); + self::assertStringContainsString( + 'Changed title after plain update', + $this->dispatchBookItemGet(1), + 'A plain update to the record itself must still evict its own cached item response.' + ); + } + + /** + * Updates the row directly via SQL, bypassing t3api entirely, and detaches Extbase's + * persistence session identity map so the next repository fetch cannot serve the stale + * in-memory object it may have loaded earlier - without this, a `dispatchCollectionGet()` + * call could return pre-update data purely because of Extbase's own object identity cache, + * which would look identical to (and be mistaken for) a t3api response cache hit. + */ + private function updateBookTitle(string $title): void + { + $this->getConnectionPool()->getConnectionForTable(self::TABLE) + ->update(self::TABLE, ['title' => $title], ['uid' => 1]); + + $this->get(PersistenceManagerInterface::class)->clearState(); + } + + /** + * Updates an author row directly via SQL, bypassing t3api entirely, and detaches Extbase's + * persistence session identity map for the same reason `updateBookTitle()` does. + */ + private function updateAuthorName(string $currentName, string $newName): void + { + $this->getConnectionPool()->getConnectionForTable(self::AUTHOR_TABLE) + ->update(self::AUTHOR_TABLE, ['name' => $newName], ['name' => $currentName]); + + $this->get(PersistenceManagerInterface::class)->clearState(); + } + + /** + * Deletes every fixture row directly via SQL, bypassing t3api entirely, and detaches + * Extbase's persistence session identity map for the same reason `updateBookTitle()` does. + */ + private function deleteAllBooks(): void + { + $this->getConnectionPool()->getConnectionForTable(self::TABLE)->truncate(self::TABLE); + + $this->get(PersistenceManagerInterface::class)->clearState(); + } + + /** + * Inserts a row directly via SQL, bypassing t3api entirely, and detaches Extbase's + * persistence session identity map for the same reason `updateBookTitle()` does. + */ + private function insertBook(string $title): int + { + $connection = $this->getConnectionPool()->getConnectionForTable(self::TABLE); + $connection->insert(self::TABLE, ['pid' => 0, 'title' => $title]); + $newUid = (int)$connection->lastInsertId(); + + $this->get(PersistenceManagerInterface::class)->clearState(); + + return $newUid; + } + + private function dispatchCollectionGet(string $url = 'https://example.com/_api/books'): string + { + $symfonyRequest = SymfonyRequest::create($url, 'GET'); + $requestContext = (new RequestContext())->fromRequest($symfonyRequest); + $response = new Response(); + + return $this->get(Bootstrap::class) + ->processOperationByRequest($requestContext, $symfonyRequest, $response); + } + + private function dispatchBookItemGet(int $uid): string + { + $symfonyRequest = SymfonyRequest::create('https://example.com/_api/books/' . $uid, 'GET'); + $requestContext = (new RequestContext())->fromRequest($symfonyRequest); + $response = new Response(); + + return $this->get(Bootstrap::class) + ->processOperationByRequest($requestContext, $symfonyRequest, $response); + } + + /** + * Writes the site configuration file directly instead of going through + * `TYPO3\CMS\Core\Configuration\SiteWriter`, which only exists from TYPO3 13.1 onwards + * (in earlier versions writing was a responsibility of `SiteConfiguration` itself) - this + * keeps the test working across the whole TYPO3 12/13/14 support range. + */ + private function writeSiteConfiguration(): void + { + $siteConfigurationDirectory = Environment::getConfigPath() . '/sites/' . self::SITE_IDENTIFIER; + GeneralUtility::mkdir_deep($siteConfigurationDirectory); + file_put_contents($siteConfigurationDirectory . '/config.yaml', Yaml::dump([ + 'rootPageId' => 1, + 'base' => '/', + 'languages' => [ + 0 => [ + 'title' => 'English', + 'enabled' => true, + 'languageId' => 0, + 'base' => '/', + 'locale' => 'en_US.UTF-8', + 'navigationTitle' => 'English', + ], + ], + 'routeEnhancers' => [ + 'T3apiResourceEnhancer' => [ + 'type' => 'T3apiResourceEnhancer', + 'basePath' => '_api', + ], + ], + ], 99, 2)); + + $this->flushSiteConfigurationCaches(); + } + + /** + * `SiteConfiguration` caches its resolved sites under the fixed identifier `sites-configuration` + * in both the `core` and `runtime` caches; a direct file write bypasses the invalidation that + * `SiteWriter::write()`/`SiteConfiguration::write()` would otherwise trigger via + * `SiteConfigurationChangedEvent`, so both entries are removed explicitly here. + */ + private function flushSiteConfigurationCaches(): void + { + $cacheManager = $this->get(CacheManager::class); + $cacheManager->getCache('core')->remove('sites-configuration'); + $cacheManager->getCache('runtime')->remove('sites-configuration'); + } +} diff --git a/Tests/Functional/Domain/Repository/ApiResourceRepositoryTest.php b/Tests/Functional/Domain/Repository/ApiResourceRepositoryTest.php index 7b41d5b..3811e08 100644 --- a/Tests/Functional/Domain/Repository/ApiResourceRepositoryTest.php +++ b/Tests/Functional/Domain/Repository/ApiResourceRepositoryTest.php @@ -11,6 +11,13 @@ use TYPO3\CMS\Core\Cache\CacheManager; use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase; +/** + * TODO: rework to the fixture-extension approach established by ResponseCacheDispatcherTest - + * load a dedicated test extension from Tests/Functional/Fixtures/Extensions/ via + * $testExtensionsToLoad and assert against the domain models it provides, instead of mocking + * every collaborator and invoking a protected method via reflection only to assert an empty + * result on a bare install. Follow that pattern for any new functional test. + */ class ApiResourceRepositoryTest extends FunctionalTestCase { protected array $testExtensionsToLoad = ['typo3conf/ext/t3api']; diff --git a/Tests/Functional/Fixtures/Extensions/t3api_response_cache_test/Classes/Domain/Model/Author.php b/Tests/Functional/Fixtures/Extensions/t3api_response_cache_test/Classes/Domain/Model/Author.php new file mode 100644 index 0000000..a764aae --- /dev/null +++ b/Tests/Functional/Fixtures/Extensions/t3api_response_cache_test/Classes/Domain/Model/Author.php @@ -0,0 +1,22 @@ +name; + } +} diff --git a/Tests/Functional/Fixtures/Extensions/t3api_response_cache_test/Classes/Domain/Model/Book.php b/Tests/Functional/Fixtures/Extensions/t3api_response_cache_test/Classes/Domain/Model/Book.php new file mode 100644 index 0000000..cbaed4e --- /dev/null +++ b/Tests/Functional/Fixtures/Extensions/t3api_response_cache_test/Classes/Domain/Model/Book.php @@ -0,0 +1,53 @@ +title; + } + + public function getAuthor(): ?Author + { + return $this->author; + } +} diff --git a/Tests/Functional/Fixtures/Extensions/t3api_response_cache_test/Configuration/Services.yaml b/Tests/Functional/Fixtures/Extensions/t3api_response_cache_test/Configuration/Services.yaml new file mode 100644 index 0000000..7aead45 --- /dev/null +++ b/Tests/Functional/Fixtures/Extensions/t3api_response_cache_test/Configuration/Services.yaml @@ -0,0 +1,11 @@ +services: + _defaults: + autowire: true + autoconfigure: true + public: false + + T3apiTests\ResponseCacheTest\: + resource: '../Classes/*' + + SourceBroker\T3api\Dispatcher\Bootstrap: + public: true diff --git a/Tests/Functional/Fixtures/Extensions/t3api_response_cache_test/Configuration/TCA/tx_responsecachetest_domain_model_author.php b/Tests/Functional/Fixtures/Extensions/t3api_response_cache_test/Configuration/TCA/tx_responsecachetest_domain_model_author.php new file mode 100644 index 0000000..e101ac7 --- /dev/null +++ b/Tests/Functional/Fixtures/Extensions/t3api_response_cache_test/Configuration/TCA/tx_responsecachetest_domain_model_author.php @@ -0,0 +1,25 @@ + [ + 'title' => 'Author', + 'label' => 'name', + 'delete' => 'deleted', + 'enablecolumns' => [ + 'disabled' => 'hidden', + 'starttime' => 'starttime', + 'endtime' => 'endtime', + ], + ], + 'columns' => [ + 'name' => [ + 'label' => 'Name', + 'config' => [ + 'type' => 'input', + ], + ], + ], + 'types' => [ + '0' => ['showitem' => 'name'], + ], +]; diff --git a/Tests/Functional/Fixtures/Extensions/t3api_response_cache_test/Configuration/TCA/tx_responsecachetest_domain_model_book.php b/Tests/Functional/Fixtures/Extensions/t3api_response_cache_test/Configuration/TCA/tx_responsecachetest_domain_model_book.php new file mode 100644 index 0000000..e167d88 --- /dev/null +++ b/Tests/Functional/Fixtures/Extensions/t3api_response_cache_test/Configuration/TCA/tx_responsecachetest_domain_model_book.php @@ -0,0 +1,35 @@ + [ + 'title' => 'Book', + 'label' => 'title', + 'delete' => 'deleted', + 'enablecolumns' => [ + 'disabled' => 'hidden', + 'starttime' => 'starttime', + 'endtime' => 'endtime', + ], + ], + 'columns' => [ + 'title' => [ + 'label' => 'Title', + 'config' => [ + 'type' => 'input', + ], + ], + 'author' => [ + 'label' => 'Author', + 'config' => [ + 'type' => 'select', + 'renderType' => 'selectSingle', + 'foreign_table' => 'tx_responsecachetest_domain_model_author', + 'foreign_table_where' => 'ORDER BY tx_responsecachetest_domain_model_author.name ASC', + 'default' => 0, + ], + ], + ], + 'types' => [ + '0' => ['showitem' => 'title, author'], + ], +]; diff --git a/Tests/Functional/Fixtures/Extensions/t3api_response_cache_test/composer.json b/Tests/Functional/Fixtures/Extensions/t3api_response_cache_test/composer.json new file mode 100644 index 0000000..39ac581 --- /dev/null +++ b/Tests/Functional/Fixtures/Extensions/t3api_response_cache_test/composer.json @@ -0,0 +1,18 @@ +{ + "name": "t3api/response-cache-test", + "type": "typo3-cms-extension", + "license": "GPL-2.0-or-later", + "require": { + "typo3/cms-core": "*" + }, + "autoload": { + "psr-4": { + "T3apiTests\\ResponseCacheTest\\": "Classes/" + } + }, + "extra": { + "typo3/cms": { + "extension-key": "t3api_response_cache_test" + } + } +} diff --git a/Tests/Functional/Fixtures/Extensions/t3api_response_cache_test/ext_emconf.php b/Tests/Functional/Fixtures/Extensions/t3api_response_cache_test/ext_emconf.php new file mode 100644 index 0000000..bf3767a --- /dev/null +++ b/Tests/Functional/Fixtures/Extensions/t3api_response_cache_test/ext_emconf.php @@ -0,0 +1,14 @@ + 't3api response cache test fixture', + 'category' => 'example', + 'state' => 'stable', + 'version' => '1.0.0', + 'constraints' => [ + 'depends' => [ + 't3api' => '', + ], + ], +]; diff --git a/Tests/Functional/Fixtures/Extensions/t3api_response_cache_test/ext_tables.sql b/Tests/Functional/Fixtures/Extensions/t3api_response_cache_test/ext_tables.sql new file mode 100644 index 0000000..7f7cadc --- /dev/null +++ b/Tests/Functional/Fixtures/Extensions/t3api_response_cache_test/ext_tables.sql @@ -0,0 +1,8 @@ +CREATE TABLE tx_responsecachetest_domain_model_book ( + title varchar(255) DEFAULT '' NOT NULL, + author int(11) unsigned DEFAULT '0' NOT NULL +); + +CREATE TABLE tx_responsecachetest_domain_model_author ( + name varchar(255) DEFAULT '' NOT NULL +); diff --git a/Tests/Functional/Fixtures/authors.csv b/Tests/Functional/Fixtures/authors.csv new file mode 100644 index 0000000..cf73fe5 --- /dev/null +++ b/Tests/Functional/Fixtures/authors.csv @@ -0,0 +1,4 @@ +"tx_responsecachetest_domain_model_author",, +,"uid","pid","name" +,1,0,"Author A" +,2,0,"Author B" diff --git a/Tests/Functional/Fixtures/books.csv b/Tests/Functional/Fixtures/books.csv new file mode 100644 index 0000000..1a6f97c --- /dev/null +++ b/Tests/Functional/Fixtures/books.csv @@ -0,0 +1,4 @@ +"tx_responsecachetest_domain_model_book",,,, +,"uid","pid","title","author" +,1,0,"First book",1 +,2,0,"Second book",2 diff --git a/Tests/Unit/Domain/Model/CacheInvalidationSettingsTest.php b/Tests/Unit/Domain/Model/CacheInvalidationSettingsTest.php new file mode 100644 index 0000000..a8d4108 --- /dev/null +++ b/Tests/Unit/Domain/Model/CacheInvalidationSettingsTest.php @@ -0,0 +1,132 @@ +getTags()); + self::assertSame([], CacheInvalidationSettings::create()->getTagExpressions()); + } + + #[Test] + public function createAppliesGivenTags(): void + { + $settings = CacheInvalidationSettings::create([ + 'tags' => ['tx_myext_domain_model_recipe'], + ]); + + self::assertSame(['tx_myext_domain_model_recipe'], $settings->getTags()); + } + + #[Test] + public function createAppliesGivenTagExpressions(): void + { + $settings = CacheInvalidationSettings::create([ + 'tagExpressions' => ["'fe_user_' ~ user.getUid()"], + ]); + + self::assertSame(["'fe_user_' ~ user.getUid()"], $settings->getTagExpressions()); + } + + /** + * Mirrors an operation declaring its own `attributes={"cacheInvalidation"={"tags"={...}}}` + * block - the given tags override the resource-level value wholesale (no per-entry merging). + */ + #[Test] + public function createOverridesBaseTagsWhenAttributesAreGiven(): void + { + $base = CacheInvalidationSettings::create(['tags' => ['resource_tag']]); + + $overridden = CacheInvalidationSettings::create(['tags' => ['operation_tag']], $base); + + self::assertSame(['operation_tag'], $overridden->getTags()); + } + + /** + * Mirrors an operation declaring only its own `tagExpressions` - the given key overrides the + * resource-level value wholesale, while the untouched `tags` key still cascades down from the + * resource, same as every other cacheInvalidation setting key. + */ + #[Test] + public function createOverridesTagExpressionsWhileInheritingTagsFromBase(): void + { + $base = CacheInvalidationSettings::create([ + 'tags' => ['resource_tag'], + 'tagExpressions' => ["'resource_' ~ user.getUid()"], + ]); + + $overridden = CacheInvalidationSettings::create( + ['tagExpressions' => ["'operation_' ~ user.getUid()"]], + $base + ); + + self::assertSame(['resource_tag'], $overridden->getTags()); + self::assertSame(["'operation_' ~ user.getUid()"], $overridden->getTagExpressions()); + } + + /** + * Mirrors an operation declaring no `cacheInvalidation` block of its own - the resource-level + * tags cascade down unchanged. + */ + #[Test] + public function createInheritsBaseTagsWhenAttributesAreAbsent(): void + { + $base = CacheInvalidationSettings::create(['tags' => ['resource_tag']]); + + $inherited = CacheInvalidationSettings::create([], $base); + + self::assertSame(['resource_tag'], $inherited->getTags()); + } + + #[Test] + public function createInheritsBaseTagExpressionsWhenAttributesAreAbsent(): void + { + $base = CacheInvalidationSettings::create(['tagExpressions' => ["'fe_user_' ~ user.getUid()"]]); + + $inherited = CacheInvalidationSettings::create([], $base); + + self::assertSame(["'fe_user_' ~ user.getUid()"], $inherited->getTagExpressions()); + } + + #[Test] + public function createWithoutAttributesOrBaseWasNotExplicitlyConfigured(): void + { + self::assertFalse(CacheInvalidationSettings::create()->wasExplicitlyConfigured()); + } + + #[Test] + public function createWithNonEmptyAttributesWasExplicitlyConfigured(): void + { + self::assertTrue( + CacheInvalidationSettings::create(['tags' => ['tx_myext_domain_model_recipe']])->wasExplicitlyConfigured() + ); + } + + /** + * Mirrors an operation declaring no `attributes.cacheInvalidation` block of its own while the + * resource level does - the resource-level block cascades its resolved values (e.g. `tags`) + * onto the operation, but the operation's own settings object must NOT be reported as + * explicitly configured, since it did not declare anything itself. The flag is always derived + * from the CURRENT `create()` call's `$attributes`, never inherited from the base, even though + * the base itself stays explicitly configured. + */ + #[Test] + public function createWithEmptyAttributesAndExplicitBaseIsNotExplicitlyConfiguredWhileBaseStaysExplicit(): void + { + $explicitBase = CacheInvalidationSettings::create(['tags' => ['resource_tag']]); + + $inherited = CacheInvalidationSettings::create([], $explicitBase); + + self::assertFalse($inherited->wasExplicitlyConfigured()); + self::assertTrue($explicitBase->wasExplicitlyConfigured()); + } +} diff --git a/Tests/Unit/Domain/Model/ResponseCacheSettingsTest.php b/Tests/Unit/Domain/Model/ResponseCacheSettingsTest.php new file mode 100644 index 0000000..3199536 --- /dev/null +++ b/Tests/Unit/Domain/Model/ResponseCacheSettingsTest.php @@ -0,0 +1,298 @@ +isEnabled()); + self::assertSame(ResponseCacheSettings::DEFAULT_LIFETIME_IN_SECONDS, $settings->getLifetime()); + self::assertSame('', $settings->getReadCondition()); + self::assertSame('', $settings->getWriteCondition()); + self::assertSame([], $settings->getIdentifierExpressions()); + self::assertSame([], $settings->getTags()); + self::assertSame([], $settings->getTagExpressions()); + self::assertSame([], $settings->getMemberTagExpressions()); + } + + #[Test] + public function createAppliesGivenValues(): void + { + $settings = ResponseCacheSettings::create([ + 'lifetime' => 3600, + 'readCondition' => "request.query.get('refresh') == null", + 'writeCondition' => "request.query.get('nocache') == null", + 'identifierExpressions' => [ + "request.headers.get('X-App-Version')", + "context.getPropertyFromAspect('frontend.user', 'groupIds', '')", + ], + 'tags' => ['pages', 'tt_content'], + 'tagExpressions' => ["user.isLoggedIn() ? 'fe_user_' ~ user.getUid() : ''"], + 'memberTagExpressions' => ["'author_' ~ object.getAuthor().getUid()"], + ]); + + self::assertSame(3600, $settings->getLifetime()); + self::assertSame("request.query.get('refresh') == null", $settings->getReadCondition()); + self::assertSame("request.query.get('nocache') == null", $settings->getWriteCondition()); + self::assertSame( + [ + "request.headers.get('X-App-Version')", + "context.getPropertyFromAspect('frontend.user', 'groupIds', '')", + ], + $settings->getIdentifierExpressions() + ); + self::assertSame(['pages', 'tt_content'], $settings->getTags()); + self::assertSame( + ["user.isLoggedIn() ? 'fe_user_' ~ user.getUid() : ''"], + $settings->getTagExpressions() + ); + self::assertSame( + ["'author_' ~ object.getAuthor().getUid()"], + $settings->getMemberTagExpressions() + ); + } + + #[Test] + public function createWithNonEmptyAttributesWithoutEnabledKeyEnablesCaching(): void + { + self::assertTrue(ResponseCacheSettings::create(['lifetime' => 3600])->isEnabled()); + } + + #[Test] + public function createWithEmptyAttributesInheritsEnabledBaseState(): void + { + $enabledBase = ResponseCacheSettings::create(['lifetime' => 3600]); + + self::assertTrue(ResponseCacheSettings::create([], $enabledBase)->isEnabled()); + } + + #[Test] + public function createWithEmptyAttributesInheritsDisabledBaseState(): void + { + $disabledBase = ResponseCacheSettings::create(); + + self::assertFalse(ResponseCacheSettings::create([], $disabledBase)->isEnabled()); + } + + #[Test] + public function createWithExplicitEnabledFalseDisablesCachingEvenWithOtherAttributes(): void + { + $settings = ResponseCacheSettings::create(['enabled' => false, 'lifetime' => 60]); + + self::assertFalse($settings->isEnabled()); + } + + #[Test] + public function createWithExplicitEnabledTrueOverridesDisabledBase(): void + { + $disabledBase = ResponseCacheSettings::create(); + + self::assertTrue(ResponseCacheSettings::create(['enabled' => true], $disabledBase)->isEnabled()); + } + + /** + * Mirrors an operation declaring `"attributes"={"cache"={"lifetime"=60}}` while the resource + * itself declares no `cache` block at all - the operation alone gets caching enabled. + */ + #[Test] + public function createWithNonEmptyAttributesEnablesCachingEvenWhenBaseIsDisabled(): void + { + $disabledBase = ResponseCacheSettings::create(); + + self::assertTrue(ResponseCacheSettings::create(['lifetime' => 60], $disabledBase)->isEnabled()); + } + + #[Test] + public function createOverridesBaseValuesWhenAttributesAreGiven(): void + { + $base = ResponseCacheSettings::create(['enabled' => true, 'lifetime' => 3600]); + + $overridden = ResponseCacheSettings::create(['enabled' => true, 'lifetime' => 60], $base); + + self::assertTrue($overridden->isEnabled()); + self::assertSame(60, $overridden->getLifetime()); + } + + #[Test] + public function createInheritsBaseValuesWhenAttributeKeysAreAbsent(): void + { + $base = ResponseCacheSettings::create([ + 'enabled' => true, + 'lifetime' => 3600, + 'readCondition' => "request.query.get('refresh') == null", + 'writeCondition' => "request.query.get('nocache') == null", + 'identifierExpressions' => ["request.headers.get('X-App-Version')"], + 'tags' => ['pages'], + 'tagExpressions' => ["'fe_user_' ~ user.getUid()"], + 'memberTagExpressions' => ["'author_' ~ object.getAuthor().getUid()"], + ]); + + $inherited = ResponseCacheSettings::create([], $base); + + self::assertTrue($inherited->isEnabled()); + self::assertSame(3600, $inherited->getLifetime()); + self::assertSame("request.query.get('refresh') == null", $inherited->getReadCondition()); + self::assertSame("request.query.get('nocache') == null", $inherited->getWriteCondition()); + self::assertSame(["request.headers.get('X-App-Version')"], $inherited->getIdentifierExpressions()); + self::assertSame(["'fe_user_' ~ user.getUid()"], $inherited->getTagExpressions()); + self::assertSame(['pages'], $inherited->getTags()); + self::assertSame(["'author_' ~ object.getAuthor().getUid()"], $inherited->getMemberTagExpressions()); + } + + /** + * Mirrors an operation declaring only `"identifierExpressions"` in its own + * `attributes={"cache"={...}}` block - the given array overrides the resource-level value + * wholesale (no per-entry merging), while the untouched `readCondition` key still cascades + * down from the resource, same as every other cache setting key. + */ + #[Test] + public function createOverridesIdentifierExpressionsWhileInheritingReadConditionFromBase(): void + { + $base = ResponseCacheSettings::create([ + 'readCondition' => "request.query.get('refresh') == null", + 'identifierExpressions' => ["request.headers.get('X-App-Version')"], + ]); + + $overridden = ResponseCacheSettings::create( + ['identifierExpressions' => ["request.headers.get('X-Tenant')"]], + $base + ); + + self::assertSame("request.query.get('refresh') == null", $overridden->getReadCondition()); + self::assertSame(["request.headers.get('X-Tenant')"], $overridden->getIdentifierExpressions()); + } + + /** + * Mirrors an operation declaring only `"tags"` in its own `attributes={"cache"={...}}` block - + * the given key overrides the resource-level value, while the untouched `readCondition` key + * still cascades down from the resource, same as every other cache setting key. + */ + #[Test] + public function createOverridesTagsWhileInheritingReadConditionFromBase(): void + { + $base = ResponseCacheSettings::create([ + 'readCondition' => "request.query.get('refresh') == null", + 'tags' => ['resource_tag'], + ]); + + $overridden = ResponseCacheSettings::create(['tags' => ['operation_tag']], $base); + + self::assertSame("request.query.get('refresh') == null", $overridden->getReadCondition()); + self::assertSame(['operation_tag'], $overridden->getTags()); + } + + /** + * Mirrors an operation declaring only `"tagExpressions"` in its own + * `attributes={"cache"={...}}` block - the given key overrides the resource-level value, while + * the untouched `readCondition` key still cascades down from the resource, same as every other + * cache setting key. + */ + #[Test] + public function createOverridesTagExpressionsWhileInheritingReadConditionFromBase(): void + { + $base = ResponseCacheSettings::create([ + 'readCondition' => "request.query.get('refresh') == null", + 'tagExpressions' => ["'resource_' ~ user.getUid()"], + ]); + + $overridden = ResponseCacheSettings::create( + ['tagExpressions' => ["'operation_' ~ user.getUid()"]], + $base + ); + + self::assertSame("request.query.get('refresh') == null", $overridden->getReadCondition()); + self::assertSame(["'operation_' ~ user.getUid()"], $overridden->getTagExpressions()); + } + + #[Test] + public function createInheritsTagExpressionsWhenAttributesAreAbsent(): void + { + $base = ResponseCacheSettings::create(['tagExpressions' => ["'resource_' ~ user.getUid()"]]); + + $inherited = ResponseCacheSettings::create([], $base); + + self::assertSame(["'resource_' ~ user.getUid()"], $inherited->getTagExpressions()); + } + + /** + * Mirrors an operation declaring only `"memberTagExpressions"` in its own + * `attributes={"cache"={...}}` block - the given key overrides the resource-level value, while + * the untouched `readCondition` key still cascades down from the resource, same as every other + * cache setting key. + */ + #[Test] + public function createOverridesMemberTagExpressionsWhileInheritingReadConditionFromBase(): void + { + $base = ResponseCacheSettings::create([ + 'readCondition' => "request.query.get('refresh') == null", + 'memberTagExpressions' => ["'resource_author_' ~ object.getAuthor().getUid()"], + ]); + + $overridden = ResponseCacheSettings::create( + ['memberTagExpressions' => ["'operation_author_' ~ object.getAuthor().getUid()"]], + $base + ); + + self::assertSame("request.query.get('refresh') == null", $overridden->getReadCondition()); + self::assertSame( + ["'operation_author_' ~ object.getAuthor().getUid()"], + $overridden->getMemberTagExpressions() + ); + } + + #[Test] + public function createInheritsMemberTagExpressionsWhenAttributesAreAbsent(): void + { + $base = ResponseCacheSettings::create([ + 'memberTagExpressions' => ["'author_' ~ object.getAuthor().getUid()"], + ]); + + $inherited = ResponseCacheSettings::create([], $base); + + self::assertSame( + ["'author_' ~ object.getAuthor().getUid()"], + $inherited->getMemberTagExpressions() + ); + } + + #[Test] + public function createWithoutAttributesOrBaseWasNotExplicitlyConfigured(): void + { + self::assertFalse(ResponseCacheSettings::create()->wasExplicitlyConfigured()); + } + + #[Test] + public function createWithNonEmptyAttributesWasExplicitlyConfigured(): void + { + self::assertTrue(ResponseCacheSettings::create(['lifetime' => 3600])->wasExplicitlyConfigured()); + } + + /** + * Mirrors an operation declaring no `attributes.cache` block of its own while the resource + * level does - the resource-level block cascades its resolved values (e.g. `enabled`) onto the + * operation, but the operation's own settings object must NOT be reported as explicitly + * configured, since it did not declare anything itself. The flag is always derived from the + * CURRENT `create()` call's `$attributes`, never inherited from the base, even though the base + * itself stays explicitly configured. + */ + #[Test] + public function createWithEmptyAttributesAndExplicitBaseIsNotExplicitlyConfiguredWhileBaseStaysExplicit(): void + { + $explicitBase = ResponseCacheSettings::create(['lifetime' => 3600]); + + $inherited = ResponseCacheSettings::create([], $explicitBase); + + self::assertFalse($inherited->wasExplicitlyConfigured()); + self::assertTrue($explicitBase->wasExplicitlyConfigured()); + } +} diff --git a/Tests/Unit/EventListener/FlushCacheAfterSaveListenerTest.php b/Tests/Unit/EventListener/FlushCacheAfterSaveListenerTest.php new file mode 100644 index 0000000..6b39195 --- /dev/null +++ b/Tests/Unit/EventListener/FlushCacheAfterSaveListenerTest.php @@ -0,0 +1,44 @@ +createMock(FrontendInterface::class); + $cache->expects(self::once())->method('flushByTags')->with(['tx_foo_5']); + + (new FlushCacheAfterSaveListener($cache))->afterUpdated(new RecordUpdatedEvent(5, 'tx_foo')); + } + + #[Test] + public function flushesCollectionAndRecordTagsWhenCollectionMembershipChanges(): void + { + $cache = $this->createMock(FrontendInterface::class); + $cache->expects(self::once())->method('flushByTags')->with(['tx_foo--collection', 'tx_foo_5']); + + (new FlushCacheAfterSaveListener($cache))->afterUpdated( + new RecordUpdatedEvent(5, 'tx_foo', changesCollectionMembership: true) + ); + } + + #[Test] + public function flushesCollectionAndRecordTagsOnDelete(): void + { + $cache = $this->createMock(FrontendInterface::class); + $cache->expects(self::once())->method('flushByTags')->with(['tx_foo--collection', 'tx_foo_7']); + + (new FlushCacheAfterSaveListener($cache))->afterDeleted(new RecordDeletedEvent(7, 'tx_foo')); + } +} diff --git a/Tests/Unit/EventListener/PersistenceEventListenerTest.php b/Tests/Unit/EventListener/PersistenceEventListenerTest.php new file mode 100644 index 0000000..89322dc --- /dev/null +++ b/Tests/Unit/EventListener/PersistenceEventListenerTest.php @@ -0,0 +1,91 @@ +createDomainObjectMock(5); + + $eventDispatcher = $this->createMock(EventDispatcherInterface::class); + $eventDispatcher->expects(self::once()) + ->method('dispatch') + ->with(self::callback( + static fn(RecordUpdatedEvent $event): bool => $event->getUid() === 5 + && $event->getTable() === 'tx_foo' + && $event->changesCollectionMembership() === true + )); + + (new PersistenceEventListener($this->createDataMapFactory($object, 'tx_foo'), $eventDispatcher)) + ->entityAdded(new EntityAddedToPersistenceEvent($object)); + } + + #[Test] + public function entityUpdatedDispatchesRecordUpdatedEventWithoutMembershipChange(): void + { + $object = $this->createDomainObjectMock(5); + + $eventDispatcher = $this->createMock(EventDispatcherInterface::class); + $eventDispatcher->expects(self::once()) + ->method('dispatch') + ->with(self::callback( + static fn(RecordUpdatedEvent $event): bool => $event->getUid() === 5 + && $event->getTable() === 'tx_foo' + && $event->changesCollectionMembership() === false + )); + + (new PersistenceEventListener($this->createDataMapFactory($object, 'tx_foo'), $eventDispatcher)) + ->entityUpdated(new EntityUpdatedInPersistenceEvent($object)); + } + + #[Test] + public function entityRemovedDispatchesRecordDeletedEvent(): void + { + $object = $this->createDomainObjectMock(9); + + $eventDispatcher = $this->createMock(EventDispatcherInterface::class); + $eventDispatcher->expects(self::once()) + ->method('dispatch') + ->with(self::callback( + static fn(RecordDeletedEvent $event): bool => $event->getUid() === 9 && $event->getTable() === 'tx_foo' + )); + + (new PersistenceEventListener($this->createDataMapFactory($object, 'tx_foo'), $eventDispatcher)) + ->entityRemoved(new EntityRemovedFromPersistenceEvent($object)); + } + + private function createDomainObjectMock(int $uid): DomainObjectInterface&MockObject + { + $object = $this->createMock(DomainObjectInterface::class); + $object->method('getUid')->willReturn($uid); + + return $object; + } + + private function createDataMapFactory(DomainObjectInterface $object, string $tableName): DataMapFactory + { + $dataMapFactory = $this->createMock(DataMapFactory::class); + $dataMapFactory->method('buildDataMap') + ->willReturnMap([[$object::class, new DataMap($object::class, $tableName)]]); + + return $dataMapFactory; + } +} diff --git a/Tests/Unit/Factory/ApiResourceFactoryTest.php b/Tests/Unit/Factory/ApiResourceFactoryTest.php new file mode 100644 index 0000000..a930c2d --- /dev/null +++ b/Tests/Unit/Factory/ApiResourceFactoryTest.php @@ -0,0 +1,149 @@ +buildApiResourceFor(CacheableBook::class)->getResponseCacheSettings(); + + self::assertTrue($responseCacheSettings->isEnabled()); + self::assertSame(3600, $responseCacheSettings->getLifetime()); + } + + #[Test] + public function apiResourceBuiltFromPlainEntityHasDisabledCacheSettings(): void + { + self::assertFalse($this->buildApiResourceFor(PlainBook::class)->getResponseCacheSettings()->isEnabled()); + } + + #[Test] + public function createApiResourceFromFqcnReturnsNullWithoutApiResourceAnnotation(): void + { + $apiResourceConfigurationValidator = $this->createMock(ApiResourceConfigurationValidator::class); + $apiResourceConfigurationValidator->expects(self::never())->method('validate'); + + self::assertNull( + (new ApiResourceFactory($apiResourceConfigurationValidator))->createApiResourceFromFqcn(PlainBook::class) + ); + } + + /** + * Configuration validation moved from a pre-construction annotation scan to + * `ApiResourceConfigurationValidator`, run by the factory on the fully built `ApiResource` + * (operations/routes attached) right before it is returned - so this test only proves the + * factory *wires* the validator correctly. The bad-configuration fixtures the old + * pre-construction assertion tests used are gone; the rules themselves are exercised directly + * against the validator in `ApiResourceConfigurationValidatorTest`. + * + * `CacheableBook` is reused here because it declares an `@ApiResource` annotation but no + * operations, so building its `ApiResource` never reaches `AbstractOperation`'s + * `RouteService::getFullApiBasePath()` call (unavailable without a bootstrapped TYPO3 site - + * see `buildApiResourceFor()` below). Going through the real `createApiResourceFromFqcn()` + * (rather than constructing `ApiResource` directly) also exercises `Pagination::create()`, + * which reads its `pagination_*` attribute keys unconditionally - in production they are + * merged into every `@ApiResource` annotation's attributes by + * `SourceBroker\T3api\Annotation\ApiResource::__construct()` reading + * `$GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['t3api']['pagination']`, populated by + * `ext_localconf.php` in production; that global is populated by hand here (`backupGlobals` + * is enabled for this suite, so PHPUnit restores it after the test). + */ + #[Test] + public function createApiResourceFromFqcnCallsValidatorExactlyOnceWithTheBuiltApiResource(): void + { + $GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['t3api']['pagination'] = PaginationTest::DEFAULT_API_RESOURCE_PAGINATION_ATTRIBUTES; + + $capturedApiResource = null; + $apiResourceConfigurationValidator = $this->createMock(ApiResourceConfigurationValidator::class); + $apiResourceConfigurationValidator->expects(self::once()) + ->method('validate') + ->with(self::isInstanceOf(ApiResource::class)) + ->willReturnCallback(static function (ApiResource $apiResource) use (&$capturedApiResource): void { + $capturedApiResource = $apiResource; + }); + + $apiResource = (new ApiResourceFactory($apiResourceConfigurationValidator)) + ->createApiResourceFromFqcn(CacheableBook::class); + + self::assertNotNull($apiResource); + self::assertSame($apiResource, $capturedApiResource); + } + + /** + * `OperationCacheableBook` declares no resource-level `cache` block (base stays disabled) + * but enables caching on its single item operation via a non-empty `attributes.cache` + * block - the resource-level settings on the factory-built `ApiResource` must stay + * disabled, while the same cascade `AbstractOperation::__construct()` performs for that + * operation's raw `attributes.cache` block against the resource-level base must resolve + * to enabled. + * + * Exercised as a direct `ResponseCacheSettings::create()` cascade instead of through a + * real `ItemOperation` instance: constructing one reaches `RouteService::getFullApiBasePath()`, + * which needs a resolvable TYPO3 site unavailable in a unit test. + */ + #[Test] + public function operationCanEnableCachingWhenResourceDeclaresNoCacheBlock(): void + { + $apiResource = $this->buildApiResourceFor(OperationCacheableBook::class); + + self::assertFalse($apiResource->getResponseCacheSettings()->isEnabled()); + + $apiResourceAnnotation = (new AnnotationReader())->getClassAnnotation( + new \ReflectionClass(OperationCacheableBook::class), + ApiResourceAnnotation::class + ); + $operationCacheAttributes = $apiResourceAnnotation->getItemOperations()['get']['attributes']['cache'] ?? []; + + $operationResponseCacheSettings = ResponseCacheSettings::create( + $operationCacheAttributes, + $apiResource->getResponseCacheSettings() + ); + + self::assertTrue($operationResponseCacheSettings->isEnabled()); + } + + /** + * Builds a real `ApiResource` for the given FQCN without going through + * `ApiResourceFactory::createApiResourceFromFqcn()`. `Pagination::create()` reads its + * `pagination_*` attribute keys unconditionally (even a disabled/unused pagination still + * touches them as fallback values), so constructing an `ApiResource` always needs those keys + * present in `attributes` - in production they come from + * `$GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['t3api']['pagination']`, populated by + * `ext_localconf.php`, which is not loaded in this unit test. Pagination and upload defaults + * are therefore supplied explicitly, and the FQCN's own declared `cache` attributes (if any) + * are read via the real annotation reader and merged in, so this exercises the same cache + * resolution `ApiResource::__construct()` performs in production. + */ + private function buildApiResourceFor(string $fqcn): ApiResource + { + $apiResourceAnnotation = (new AnnotationReader())->getClassAnnotation( + new \ReflectionClass($fqcn), + ApiResourceAnnotation::class + ); + + $attributes = PaginationTest::DEFAULT_API_RESOURCE_PAGINATION_ATTRIBUTES; + $attributes['upload'] = ['allowedFileExtensions' => ['jpg']]; + $attributes['cache'] = $apiResourceAnnotation instanceof ApiResourceAnnotation + ? ($apiResourceAnnotation->getAttributes()['cache'] ?? []) + : []; + + return new ApiResource($fqcn, new ApiResourceAnnotation(['attributes' => $attributes])); + } +} diff --git a/Tests/Unit/Fixtures/Domain/Model/CacheableBook.php b/Tests/Unit/Fixtures/Domain/Model/CacheableBook.php new file mode 100644 index 0000000..55e4d13 --- /dev/null +++ b/Tests/Unit/Fixtures/Domain/Model/CacheableBook.php @@ -0,0 +1,30 @@ +setTcaForTxFoo(); + + $eventDispatcher = $this->createMock(EventDispatcherInterface::class); + $eventDispatcher->expects(self::once()) + ->method('dispatch') + ->with(self::callback( + static fn(RecordUpdatedEvent $event): bool => $event->getUid() === 5 + && $event->getTable() === 'tx_foo' + && $event->changesCollectionMembership() === false + )); + + $fields = ['title' => 'x']; + (new ResponseCacheDataHandlerHook($eventDispatcher))->processDatamap_afterDatabaseOperations( + 'update', + 'tx_foo', + 5, + $fields, + $this->createMock(DataHandler::class) + ); + } + + #[Test] + public function dispatchesRecordUpdatedEventWithMembershipChangeWhenDisabledColumnIsTouched(): void + { + $this->setTcaForTxFoo(); + + $eventDispatcher = $this->createMock(EventDispatcherInterface::class); + $eventDispatcher->expects(self::once()) + ->method('dispatch') + ->with(self::callback( + static fn(RecordUpdatedEvent $event): bool => $event->changesCollectionMembership() === true + )); + + $fields = ['hidden' => 1]; + (new ResponseCacheDataHandlerHook($eventDispatcher))->processDatamap_afterDatabaseOperations( + 'update', + 'tx_foo', + 5, + $fields, + $this->createMock(DataHandler::class) + ); + } + + #[Test] + public function dispatchesRecordUpdatedEventWithMembershipChangeWhenStarttimeIsTouched(): void + { + $this->setTcaForTxFoo(); + + $eventDispatcher = $this->createMock(EventDispatcherInterface::class); + $eventDispatcher->expects(self::once()) + ->method('dispatch') + ->with(self::callback( + static fn(RecordUpdatedEvent $event): bool => $event->changesCollectionMembership() === true + )); + + $fields = ['starttime' => 1_700_000_000]; + (new ResponseCacheDataHandlerHook($eventDispatcher))->processDatamap_afterDatabaseOperations( + 'update', + 'tx_foo', + 5, + $fields, + $this->createMock(DataHandler::class) + ); + } + + #[Test] + public function dispatchesRecordUpdatedEventWithMembershipChangeWhenPidIsTouched(): void + { + $this->setTcaForTxFoo(); + + $eventDispatcher = $this->createMock(EventDispatcherInterface::class); + $eventDispatcher->expects(self::once()) + ->method('dispatch') + ->with(self::callback( + static fn(RecordUpdatedEvent $event): bool => $event->changesCollectionMembership() === true + )); + + $fields = ['pid' => 42]; + (new ResponseCacheDataHandlerHook($eventDispatcher))->processDatamap_afterDatabaseOperations( + 'update', + 'tx_foo', + 5, + $fields, + $this->createMock(DataHandler::class) + ); + } + + #[Test] + public function dispatchesRecordUpdatedEventWithMembershipChangeWhenSortingFieldIsTouched(): void + { + $this->setTcaForTxFoo(); + + $eventDispatcher = $this->createMock(EventDispatcherInterface::class); + $eventDispatcher->expects(self::once()) + ->method('dispatch') + ->with(self::callback( + static fn(RecordUpdatedEvent $event): bool => $event->changesCollectionMembership() === true + )); + + $fields = ['sorting' => 256]; + (new ResponseCacheDataHandlerHook($eventDispatcher))->processDatamap_afterDatabaseOperations( + 'update', + 'tx_foo', + 5, + $fields, + $this->createMock(DataHandler::class) + ); + } + + #[Test] + public function resolvesNewRecordPlaceholderToRealUidAndFlagsMembershipChange(): void + { + $eventDispatcher = $this->createMock(EventDispatcherInterface::class); + $eventDispatcher->expects(self::once()) + ->method('dispatch') + ->with(self::callback( + static fn(RecordUpdatedEvent $event): bool => $event->getUid() === 42 + && $event->changesCollectionMembership() === true + )); + + $dataHandler = $this->createMock(DataHandler::class); + $dataHandler->substNEWwithIDs = ['NEW123' => 42]; + + $fields = []; + (new ResponseCacheDataHandlerHook($eventDispatcher))->processDatamap_afterDatabaseOperations( + 'new', + 'tx_foo', + 'NEW123', + $fields, + $dataHandler + ); + } + + #[Test] + public function dispatchesRecordDeletedEventOnDeleteCommand(): void + { + $eventDispatcher = $this->createMock(EventDispatcherInterface::class); + $eventDispatcher->expects(self::once()) + ->method('dispatch') + ->with(self::callback( + static fn(RecordDeletedEvent $event): bool => $event->getUid() === 9 && $event->getTable() === 'tx_foo' + )); + + (new ResponseCacheDataHandlerHook($eventDispatcher))->processCmdmap_preProcess( + 'delete', + 'tx_foo', + 9, + '', + $this->createMock(DataHandler::class) + ); + } + + #[Test] + public function dispatchesRecordUpdatedEventWithMembershipChangeOnMoveCommand(): void + { + $eventDispatcher = $this->createMock(EventDispatcherInterface::class); + $eventDispatcher->expects(self::once()) + ->method('dispatch') + ->with(self::callback( + static fn(RecordUpdatedEvent $event): bool => $event->getUid() === 9 + && $event->getTable() === 'tx_foo' + && $event->changesCollectionMembership() === true + )); + + (new ResponseCacheDataHandlerHook($eventDispatcher))->processCmdmap_preProcess( + 'move', + 'tx_foo', + 9, + '', + $this->createMock(DataHandler::class) + ); + } + + #[Test] + public function dispatchesRecordUpdatedEventWithMembershipChangeOnUndeleteCommand(): void + { + $eventDispatcher = $this->createMock(EventDispatcherInterface::class); + $eventDispatcher->expects(self::once()) + ->method('dispatch') + ->with(self::callback( + static fn(RecordUpdatedEvent $event): bool => $event->getUid() === 9 + && $event->getTable() === 'tx_foo' + && $event->changesCollectionMembership() === true + )); + + (new ResponseCacheDataHandlerHook($eventDispatcher))->processCmdmap_preProcess( + 'undelete', + 'tx_foo', + 9, + '', + $this->createMock(DataHandler::class) + ); + } + + #[Test] + public function ignoresCopyCommand(): void + { + $eventDispatcher = $this->createMock(EventDispatcherInterface::class); + $eventDispatcher->expects(self::never())->method('dispatch'); + + (new ResponseCacheDataHandlerHook($eventDispatcher))->processCmdmap_preProcess( + 'copy', + 'tx_foo', + 9, + '', + $this->createMock(DataHandler::class) + ); + } + + private function setTcaForTxFoo(): void + { + $GLOBALS['TCA']['tx_foo']['ctrl'] = [ + 'enablecolumns' => [ + 'disabled' => 'hidden', + 'starttime' => 'starttime', + 'endtime' => 'endtime', + 'fe_group' => 'fe_group', + ], + 'sortby' => 'sorting', + ]; + } +} diff --git a/Tests/Unit/Serializer/Subscriber/CacheTagSubscriberTest.php b/Tests/Unit/Serializer/Subscriber/CacheTagSubscriberTest.php new file mode 100644 index 0000000..9cfdcf5 --- /dev/null +++ b/Tests/Unit/Serializer/Subscriber/CacheTagSubscriberTest.php @@ -0,0 +1,93 @@ +_setProperty('uid', 5); + + $dataMap = new DataMap(PlainBook::class, 'tx_test_domain_model_plainbook'); + $dataMapper = $this->createMock(DataMapper::class); + $dataMapper->method('getDataMap')->willReturnMap([[PlainBook::class, $dataMap]]); + + $collector = new CacheTagCollector(); + $collector->start(); + + $subscriber = new CacheTagSubscriber($collector, $dataMapper); + $subscriber->onPostSerialize($this->createObjectEvent($book)); + + self::assertSame( + ['tx_test_domain_model_plainbook', 'tx_test_domain_model_plainbook_5'], + $collector->stop() + ); + } + + #[Test] + public function addsOnlyTableTagForDomainObjectsWithoutUid(): void + { + $dataMap = new DataMap(PlainBook::class, 'tx_test_domain_model_plainbook'); + $dataMapper = $this->createMock(DataMapper::class); + $dataMapper->method('getDataMap')->willReturnMap([[PlainBook::class, $dataMap]]); + + $collector = new CacheTagCollector(); + $collector->start(); + + $subscriber = new CacheTagSubscriber($collector, $dataMapper); + $subscriber->onPostSerialize($this->createObjectEvent(new PlainBook())); + + self::assertSame(['tx_test_domain_model_plainbook'], $collector->stop()); + } + + #[Test] + public function doesNothingWhenCollectorIsInactive(): void + { + $dataMapper = $this->createMock(DataMapper::class); + $dataMapper->expects(self::never())->method('getDataMap'); + + $book = new PlainBook(); + $book->_setProperty('uid', 5); + + $subscriber = new CacheTagSubscriber(new CacheTagCollector(), $dataMapper); + $subscriber->onPostSerialize($this->createObjectEvent($book)); + } + + #[Test] + public function doesNothingForNonDomainObjects(): void + { + $dataMapper = $this->createMock(DataMapper::class); + $dataMapper->expects(self::never())->method('getDataMap'); + + $collector = new CacheTagCollector(); + $collector->start(); + + $subscriber = new CacheTagSubscriber($collector, $dataMapper); + $subscriber->onPostSerialize($this->createObjectEvent(new \stdClass())); + + self::assertSame([], $collector->stop()); + } + + private function createObjectEvent(object $object): ObjectEvent + { + return new ObjectEvent( + $this->createMock(Context::class), + $object, + ['name' => get_class($object), 'params' => []] + ); + } +} diff --git a/Tests/Unit/Service/ApiResourceConfigurationValidatorTest.php b/Tests/Unit/Service/ApiResourceConfigurationValidatorTest.php new file mode 100644 index 0000000..b019203 --- /dev/null +++ b/Tests/Unit/Service/ApiResourceConfigurationValidatorTest.php @@ -0,0 +1,142 @@ +validator = new ApiResourceConfigurationValidator(); + } + + #[Test] + public function validateThrowsWhenNonGetOperationExplicitlyConfiguresResponseCache(): void + { + $operation = $this->createOperation( + key: 'add', + isMethodGet: false, + responseCacheSettings: ResponseCacheSettings::create(['lifetime' => 3600]) + ); + + try { + $this->validator->validate($this->createApiResourceWithOperations('Vendor\\Ext\\Domain\\Model\\Book', $operation)); + self::fail('Expected InvalidArgumentException was not thrown.'); + } catch (\InvalidArgumentException $invalidArgumentException) { + self::assertSame(1783923801687, $invalidArgumentException->getCode()); + self::assertStringContainsString('add', $invalidArgumentException->getMessage()); + self::assertStringContainsString('Vendor\\Ext\\Domain\\Model\\Book', $invalidArgumentException->getMessage()); + } + } + + #[Test] + public function validateThrowsWhenGetOperationExplicitlyConfiguresCacheInvalidation(): void + { + $operation = $this->createOperation( + key: 'get', + isMethodGet: true, + cacheInvalidationSettings: CacheInvalidationSettings::create(['tags' => ['tx_ext_domain_model_book']]) + ); + + try { + $this->validator->validate($this->createApiResourceWithOperations('Vendor\\Ext\\Domain\\Model\\Book', $operation)); + self::fail('Expected InvalidArgumentException was not thrown.'); + } catch (\InvalidArgumentException $invalidArgumentException) { + self::assertSame(1783923801688, $invalidArgumentException->getCode()); + self::assertStringContainsString('get', $invalidArgumentException->getMessage()); + self::assertStringContainsString('Vendor\\Ext\\Domain\\Model\\Book', $invalidArgumentException->getMessage()); + } + } + + /** + * A non-GET operation whose (enabled) response-cache settings were only inherited from a + * resource-level `cache` block - never declared on the operation itself - is a legitimate + * cascade the runtime simply ignores for a write operation, not a configuration mistake. The + * validator must key off `wasExplicitlyConfigured()`, not `isEnabled()`. + */ + #[Test] + public function validateDoesNotThrowWhenNonGetOperationOnlyInheritsEnabledResponseCache(): void + { + $explicitResourceLevelSettings = ResponseCacheSettings::create(['lifetime' => 3600]); + $inheritedOperationSettings = ResponseCacheSettings::create([], $explicitResourceLevelSettings); + + self::assertTrue($inheritedOperationSettings->isEnabled()); + self::assertFalse($inheritedOperationSettings->wasExplicitlyConfigured()); + + $operation = $this->createOperation( + key: 'add', + isMethodGet: false, + responseCacheSettings: $inheritedOperationSettings + ); + + $apiResource = $this->createApiResourceWithOperations('Vendor\\Ext\\Domain\\Model\\Book', $operation); + + $this->validator->validate($apiResource); + + self::assertTrue($inheritedOperationSettings->isEnabled(), 'sanity check unchanged after validate()'); + } + + #[Test] + public function validateDoesNotThrowForCleanResource(): void + { + $getOperation = $this->createOperation(key: 'get', isMethodGet: true); + $addOperation = $this->createOperation(key: 'add', isMethodGet: false); + + $this->validator->validate( + $this->createApiResourceWithOperations('Vendor\\Ext\\Domain\\Model\\Book', $getOperation, $addOperation) + ); + + self::assertFalse($getOperation->getResponseCacheSettings()->wasExplicitlyConfigured()); + self::assertFalse($addOperation->getCacheInvalidationSettings()->wasExplicitlyConfigured()); + } + + private function createOperation( + string $key, + bool $isMethodGet, + ?ResponseCacheSettings $responseCacheSettings = null, + ?CacheInvalidationSettings $cacheInvalidationSettings = null + ): OperationInterface { + $operation = $this->createMock(OperationInterface::class); + $operation->method('getKey')->willReturn($key); + $operation->method('isMethodGet')->willReturn($isMethodGet); + $operation->method('getResponseCacheSettings')->willReturn( + $responseCacheSettings ?? ResponseCacheSettings::create() + ); + $operation->method('getCacheInvalidationSettings')->willReturn( + $cacheInvalidationSettings ?? CacheInvalidationSettings::create() + ); + + return $operation; + } + + private function createApiResourceWithOperations(string $entity, OperationInterface ...$operations): ApiResource + { + $apiResource = $this->createMock(ApiResource::class); + $apiResource->method('getEntity')->willReturn($entity); + $apiResource->method('getOperations')->willReturn($operations); + + return $apiResource; + } +} diff --git a/Tests/Unit/Service/CacheTagCollectorTest.php b/Tests/Unit/Service/CacheTagCollectorTest.php new file mode 100644 index 0000000..e6b7027 --- /dev/null +++ b/Tests/Unit/Service/CacheTagCollectorTest.php @@ -0,0 +1,55 @@ +start(); + $collector->addTags('tx_foo', 'tx_foo_1'); + $collector->addTags('tx_foo', 'tx_foo_2'); + + self::assertSame(['tx_foo', 'tx_foo_1', 'tx_foo_2'], $collector->stop()); + } + + #[Test] + public function ignoresTagsWhenNotCollecting(): void + { + $collector = new CacheTagCollector(); + $collector->addTags('tx_foo'); + + self::assertFalse($collector->isCollecting()); + self::assertSame([], $collector->stop()); + } + + #[Test] + public function startResetsPreviouslyCollectedTags(): void + { + $collector = new CacheTagCollector(); + $collector->start(); + $collector->addTags('tx_old'); + $collector->start(); + $collector->addTags('tx_new'); + + self::assertSame(['tx_new'], $collector->stop()); + } + + #[Test] + public function stopEndsCollecting(): void + { + $collector = new CacheTagCollector(); + $collector->start(); + $collector->stop(); + + self::assertFalse($collector->isCollecting()); + } +} diff --git a/Tests/Unit/Service/ExpressionLanguageServiceTest.php b/Tests/Unit/Service/ExpressionLanguageServiceTest.php new file mode 100644 index 0000000..50c5544 --- /dev/null +++ b/Tests/Unit/Service/ExpressionLanguageServiceTest.php @@ -0,0 +1,67 @@ +user->isLoggedIn); + self::assertFalse($variables['frontend']->user->isLoggedIn); + } + + #[Test] + public function buildsBackendUserVariableFromBackendUserAspect(): void + { + $backendUser = $this->createMock(BackendUserAuthentication::class); + $backendUser->method('isAdmin')->willReturn(true); + $backendUser->userid_column = 'uid'; + $backendUser->user = ['uid' => 7]; + $backendUser->userGroupsUID = [1, 2]; + + $context = new Context(); + $context->setAspect('backend.user', new UserAspect($backendUser)); + + $variables = ExpressionLanguageService::getUserVariables($context); + + self::assertSame(7, $variables['backend']->user->userId); + self::assertTrue($variables['backend']->user->isAdmin); + self::assertTrue($variables['backend']->user->isLoggedIn); + self::assertSame('1,2', $variables['backend']->user->userGroupList); + } + + #[Test] + public function buildsFrontendUserVariableFromFrontendUserAspect(): void + { + $frontendUser = $this->createMock(AbstractUserAuthentication::class); + $frontendUser->userid_column = 'uid'; + $frontendUser->user = ['uid' => 3]; + + $context = new Context(); + $context->setAspect('frontend.user', new UserAspect($frontendUser)); + + $variables = ExpressionLanguageService::getUserVariables($context); + + self::assertSame(3, $variables['frontend']->user->userId); + self::assertTrue($variables['frontend']->user->isLoggedIn); + } +} diff --git a/Tests/Unit/Service/OperationResponseCacheTest.php b/Tests/Unit/Service/OperationResponseCacheTest.php new file mode 100644 index 0000000..59eaa93 --- /dev/null +++ b/Tests/Unit/Service/OperationResponseCacheTest.php @@ -0,0 +1,1228 @@ +responseCacheService = $this->createMock(ResponseCacheService::class); + $this->cacheTagCollector = new CacheTagCollector(); + $this->operationAccessChecker = $this->createMock(OperationAccessChecker::class); + $this->dataMapper = $this->createMock(DataMapper::class); + $this->responseCacheDebugHeaders = $this->createMock(ResponseCacheDebugHeaders::class); + } + + #[Test] + public function cacheHitReturnsStoredOutputWithoutProcessing(): void + { + $this->allowConditions(); + $this->responseCacheService->method('buildEntryIdentifier')->willReturn('entry-id'); + $this->responseCacheService->method('get')->willReturnMap([['entry-id', '{"cached":true}']]); + + $processorCalled = false; + $output = $this->resolve($this->processor('{"fresh":true}', $processorCalled)); + + self::assertSame('{"cached":true}', $output); + self::assertFalse($processorCalled); + } + + /** + * `resolve()` decorates the response with `ResponseCacheDebugHeaders::hit()` before returning + * the cached output - `OperationResponseCache` itself never touches headers or the + * `Environment` development gate directly, see `ResponseCacheDebugHeadersTest` for that. + */ + #[Test] + public function cacheHitDecoratesResponseViaDebugHeadersCollaborator(): void + { + $this->allowConditions(); + $this->responseCacheService->method('buildEntryIdentifier')->willReturn('entry-id'); + $this->responseCacheService->method('get')->willReturnMap([['entry-id', '{"cached":true}']]); + $this->responseCacheDebugHeaders->expects(self::once()) + ->method('hit') + ->with(self::anything(), 'entry-id'); + + $processorCalled = false; + $output = $this->resolve($this->processor('{"fresh":true}', $processorCalled)); + + self::assertSame('{"cached":true}', $output); + } + + #[Test] + public function cacheMissProcessesAndStoresOutput(): void + { + $this->allowConditions(); + $this->responseCacheService->method('buildEntryIdentifier')->willReturn('entry-id'); + $this->responseCacheService->method('get')->willReturn(null); + $this->responseCacheService->expects(self::once()) + ->method('store') + ->with('entry-id', '{"fresh":true}', self::anything(), self::anything()); + + $processorCalled = false; + $output = $this->resolve($this->processor('{"fresh":true}', $processorCalled)); + + self::assertSame('{"fresh":true}', $output); + self::assertTrue($processorCalled); + } + + /** + * `storeWithTags()` reports the exact tag set it just passed to + * `ResponseCacheService::store()` to `ResponseCacheDebugHeaders::stored()` - here the default + * operation contributes no content, literal or expression tags at all, so the reported set is + * empty, same as what `store()` itself receives. + */ + #[Test] + public function cacheMissDecoratesResponseWithStoredTagsViaDebugHeadersCollaborator(): void + { + $this->allowConditions(); + $this->responseCacheService->method('buildEntryIdentifier')->willReturn('entry-id'); + $this->responseCacheService->method('get')->willReturn(null); + $this->responseCacheDebugHeaders->expects(self::once()) + ->method('stored') + ->with(self::anything(), 'entry-id', []); + + $processorCalled = false; + $output = $this->resolve($this->processor('{"fresh":true}', $processorCalled)); + + self::assertSame('{"fresh":true}', $output); + } + + #[Test] + public function nonSuccessfulResponseIsNotStored(): void + { + $this->allowConditions(); + $this->responseCacheService->method('buildEntryIdentifier')->willReturn('entry-id'); + $this->responseCacheService->method('get')->willReturn(null); + $this->responseCacheService->expects(self::never())->method('store'); + + $processorCalled = false; + $response = new Response(); + $output = $this->resolve( + $this->processor('{"error":true}', $processorCalled, $response, 500), + response: $response + ); + + self::assertSame('{"error":true}', $output); + } + + #[Test] + public function nonCacheableRequestSkipsCacheEntirely(): void + { + $this->responseCacheService->method('buildEntryIdentifier')->willReturn(null); + $this->responseCacheService->expects(self::never())->method('get'); + $this->responseCacheService->expects(self::never())->method('store'); + + $processorCalled = false; + $output = $this->resolve($this->processor('{"fresh":true}', $processorCalled)); + + self::assertSame('{"fresh":true}', $output); + self::assertTrue($processorCalled); + } + + #[Test] + public function disallowedReadSkipsCacheLookupButStillStores(): void + { + $this->allowConditions(read: false); + $this->responseCacheService->method('buildEntryIdentifier')->willReturn('entry-id'); + $this->responseCacheService->expects(self::never())->method('get'); + $this->responseCacheService->expects(self::once()) + ->method('store') + ->with('entry-id', '{"fresh":true}', self::anything(), self::anything()); + + $processorCalled = false; + $output = $this->resolve($this->processor('{"fresh":true}', $processorCalled)); + + self::assertSame('{"fresh":true}', $output); + self::assertTrue($processorCalled); + } + + #[Test] + public function disallowedWriteProcessesButDoesNotStore(): void + { + $this->allowConditions(write: false); + $this->responseCacheService->method('buildEntryIdentifier')->willReturn('entry-id'); + $this->responseCacheService->method('get')->willReturn(null); + $this->responseCacheService->expects(self::never())->method('store'); + + $processorCalled = false; + $output = $this->resolve($this->processor('{"fresh":true}', $processorCalled)); + + self::assertSame('{"fresh":true}', $output); + self::assertTrue($processorCalled); + } + + #[Test] + public function cacheMissSeedsResourceTableAndSingleScopeTagsForItemOperation(): void + { + $this->allowConditions(); + $this->responseCacheService->method('buildEntryIdentifier')->willReturn('entry-id'); + $this->responseCacheService->method('get')->willReturn(null); + $this->dataMapper->method('getDataMap')->willReturnMap([ + [PlainBook::class, new DataMap(PlainBook::class, 'tx_test_domain_model_plainbook')], + ]); + $this->responseCacheService->expects(self::once()) + ->method('store') + ->with( + 'entry-id', + '{"fresh":true}', + ['tx_test_domain_model_plainbook', 'tx_test_domain_model_plainbook--single'], + self::anything() + ); + + $processorCalled = false; + $output = $this->resolve( + $this->processor('{"fresh":true}', $processorCalled), + $this->createOperation(PlainBook::class) + ); + + self::assertSame('{"fresh":true}', $output); + } + + #[Test] + public function cacheMissSeedsResourceTableAndCollectionScopeTagsForCollectionOperation(): void + { + $this->allowConditions(); + $this->responseCacheService->method('buildEntryIdentifier')->willReturn('entry-id'); + $this->responseCacheService->method('get')->willReturn(null); + $this->dataMapper->method('getDataMap')->willReturnMap([ + [PlainBook::class, new DataMap(PlainBook::class, 'tx_test_domain_model_plainbook')], + ]); + $this->responseCacheService->expects(self::once()) + ->method('store') + ->with( + 'entry-id', + '{"fresh":true}', + ['tx_test_domain_model_plainbook', 'tx_test_domain_model_plainbook--collection'], + self::anything() + ); + + $processorCalled = false; + $output = $this->resolve( + $this->processor('{"fresh":true}', $processorCalled), + $this->createOperation(PlainBook::class, isCollectionOperation: true) + ); + + self::assertSame('{"fresh":true}', $output); + } + + #[Test] + public function cacheMissSkipsTableTagSeedingForNonEntityResource(): void + { + $this->allowConditions(); + $this->responseCacheService->method('buildEntryIdentifier')->willReturn('entry-id'); + $this->responseCacheService->method('get')->willReturn(null); + $this->dataMapper->expects(self::never())->method('getDataMap'); + $this->responseCacheService->expects(self::once()) + ->method('store') + ->with('entry-id', '{"fresh":true}', [], self::anything()); + + $processorCalled = false; + $output = $this->resolve( + $this->processor('{"fresh":true}', $processorCalled), + $this->createOperation(\stdClass::class) + ); + + self::assertSame('{"fresh":true}', $output); + } + + #[Test] + public function cacheMissStoresConfiguredLiteralTags(): void + { + $this->allowConditions(); + $this->responseCacheService->method('buildEntryIdentifier')->willReturn('entry-id'); + $this->responseCacheService->method('get')->willReturn(null); + $this->responseCacheService->method('isValidTag')->willReturn(true); + $this->responseCacheService->expects(self::once()) + ->method('store') + ->with('entry-id', '{"fresh":true}', ['pages', 'tx_test_domain_model_recipe'], self::anything()); + + $operation = $this->createOperation(responseCacheSettings: ResponseCacheSettings::create([ + 'enabled' => true, + 'tags' => ['pages', 'tx_test_domain_model_recipe'], + ])); + + $processorCalled = false; + $output = $this->resolve($this->processor('{"fresh":true}', $processorCalled), $operation); + + self::assertSame('{"fresh":true}', $output); + } + + /** + * `tags` are pure literals with no route substitution - a route-driven tag belongs in + * `tagExpressions` instead, via the `route` variable (see `ResponseCacheServiceTest` for + * coverage of the expression evaluating that variable). This only asserts the wiring: the + * exact `$route` given to `resolve()` reaches `evaluateStoreTagExpressions()` unchanged. + */ + #[Test] + public function routeIsForwardedToStoreTagExpressionEvaluation(): void + { + $this->allowConditions(); + $this->responseCacheService->method('buildEntryIdentifier')->willReturn('entry-id'); + $this->responseCacheService->method('get')->willReturn(null); + $this->responseCacheService->expects(self::once()) + ->method('evaluateStoreTagExpressions') + ->with(self::anything(), self::anything(), ['recipeId' => '42'], self::anything()) + ->willReturn([]); + + $operation = $this->createOperation(responseCacheSettings: ResponseCacheSettings::create([ + 'enabled' => true, + 'tagExpressions' => ["'tx_test_domain_model_recipe_' ~ route['recipeId']"], + ])); + + $processorCalled = false; + $output = $this->resolve( + $this->processor('{"fresh":true}', $processorCalled), + $operation, + route: ['recipeId' => '42'] + ); + + self::assertSame('{"fresh":true}', $output); + } + + #[Test] + public function cacheMissStoresExpressionProducedTagAlongsideStaticTags(): void + { + $this->allowConditions(); + $this->responseCacheService->method('buildEntryIdentifier')->willReturn('entry-id'); + $this->responseCacheService->method('get')->willReturn(null); + $this->responseCacheService->method('isValidTag')->willReturn(true); + $this->responseCacheService->method('evaluateStoreTagExpressions')->willReturn(['fe_user_1']); + $this->responseCacheService->expects(self::once()) + ->method('store') + ->with('entry-id', '{"fresh":true}', ['pages', 'fe_user_1'], self::anything()); + + $operation = $this->createOperation(responseCacheSettings: ResponseCacheSettings::create([ + 'enabled' => true, + 'tags' => ['pages'], + 'tagExpressions' => ["user.isLoggedIn() ? 'fe_user_' ~ user.getUid() : ''"], + ])); + + $processorCalled = false; + $output = $this->resolve($this->processor('{"fresh":true}', $processorCalled), $operation); + + self::assertSame('{"fresh":true}', $output); + } + + /** + * An empty string is the documented idiom for "no tag from this expression" (e.g. an anonymous + * visitor with `user.isLoggedIn() ? ... : ''`) - it must not be treated as a failure, unlike + * `cacheMissDoesNotStoreWhenTagExpressionEvaluationFails()` below. + */ + #[Test] + public function cacheMissStoresEntryWhenTagExpressionsResolveToNoTags(): void + { + $this->allowConditions(); + $this->responseCacheService->method('buildEntryIdentifier')->willReturn('entry-id'); + $this->responseCacheService->method('get')->willReturn(null); + $this->responseCacheService->method('evaluateStoreTagExpressions')->willReturn([]); + $this->responseCacheService->expects(self::once()) + ->method('store') + ->with('entry-id', '{"fresh":true}', [], self::anything()); + + $operation = $this->createOperation(responseCacheSettings: ResponseCacheSettings::create([ + 'enabled' => true, + 'tagExpressions' => ["user.isLoggedIn() ? 'fe_user_' ~ user.getUid() : ''"], + ])); + + $processorCalled = false; + $output = $this->resolve($this->processor('{"fresh":true}', $processorCalled), $operation); + + self::assertSame('{"fresh":true}', $output); + } + + /** + * `null` from `evaluateStoreTagExpressions()` signals a failed evaluation (or an invalid + * resulting tag) - the entry's invalidation contract can no longer be guaranteed, so it must + * not be stored at all (fail-closed). The response itself is still returned to the caller - + * only the store is skipped, the request does not fail because of it. + */ + #[Test] + public function cacheMissDoesNotStoreWhenTagExpressionEvaluationFails(): void + { + $this->allowConditions(); + $this->responseCacheService->method('buildEntryIdentifier')->willReturn('entry-id'); + $this->responseCacheService->method('get')->willReturn(null); + $this->responseCacheService->method('evaluateStoreTagExpressions')->willReturn(null); + $this->responseCacheService->expects(self::never())->method('store'); + + $operation = $this->createOperation(responseCacheSettings: ResponseCacheSettings::create([ + 'enabled' => true, + 'tagExpressions' => ['thisFunctionDoesNotExist()'], + ])); + + $processorCalled = false; + $output = $this->resolve($this->processor('{"fresh":true}', $processorCalled), $operation); + + self::assertSame('{"fresh":true}', $output); + self::assertTrue($processorCalled); + } + + /** + * An item GET's `$result` is the single result entity - `memberTagExpressions` evaluates + * exactly once, against that entity as `object`. + */ + #[Test] + public function cacheMissEvaluatesMemberTagExpressionsOnceForItemGetResultEntity(): void + { + $this->allowConditions(); + $this->responseCacheService->method('buildEntryIdentifier')->willReturn('entry-id'); + $this->responseCacheService->method('get')->willReturn(null); + + $entity = $this->createMock(AbstractDomainObject::class); + $this->responseCacheService->expects(self::once()) + ->method('evaluateStoreTagExpressions') + ->with(["'author_' ~ object.getAuthor().getUid()"], self::anything(), self::anything(), self::anything(), ['object' => $entity]) + ->willReturn(['author_1']); + $this->responseCacheService->expects(self::once()) + ->method('store') + ->with('entry-id', '{"fresh":true}', ['author_1'], self::anything()); + + $operation = $this->createOperation(responseCacheSettings: ResponseCacheSettings::create([ + 'enabled' => true, + 'memberTagExpressions' => ["'author_' ~ object.getAuthor().getUid()"], + ])); + + $processorCalled = false; + $output = $this->resolve($this->processor('{"fresh":true}', $processorCalled), $operation, result: $entity); + + self::assertSame('{"fresh":true}', $output); + } + + /** + * A collection GET's `$result` is an `AbstractCollectionResponse` - `memberTagExpressions` + * evaluates once per member, and the resulting tags are unioned and deduplicated across all of + * them, on top of the automatic content tags. + */ + #[Test] + public function cacheMissEvaluatesMemberTagExpressionsOncePerCollectionMemberAndUnionsTags(): void + { + $this->allowConditions(); + $this->responseCacheService->method('buildEntryIdentifier')->willReturn('entry-id'); + $this->responseCacheService->method('get')->willReturn(null); + + $firstMember = $this->createMock(AbstractDomainObject::class); + $secondMember = $this->createMock(AbstractDomainObject::class); + $this->responseCacheService->expects(self::exactly(2)) + ->method('evaluateStoreTagExpressions') + ->willReturnCallback(function (array $expressions, $operation, $route, $request, array $additionalVariables) use ($firstMember, $secondMember) { + return match ($additionalVariables['object']) { + $firstMember => ['author_1'], + $secondMember => ['author_2'], + default => self::fail('Unexpected member evaluated: ' . get_debug_type($additionalVariables['object'])), + }; + }); + $this->responseCacheService->expects(self::once()) + ->method('store') + ->with('entry-id', '{"fresh":true}', ['author_1', 'author_2'], self::anything()); + + $operation = $this->createOperation(responseCacheSettings: ResponseCacheSettings::create([ + 'enabled' => true, + 'memberTagExpressions' => ["'author_' ~ object.getAuthor().getUid()"], + ])); + + $processorCalled = false; + $output = $this->resolve( + $this->processor('{"fresh":true}', $processorCalled), + $operation, + result: $this->createCollectionResponseStub([$firstMember, $secondMember]) + ); + + self::assertSame('{"fresh":true}', $output); + } + + /** + * An empty collection has no member to evaluate `memberTagExpressions` against - zero + * evaluations, zero member tags, and the store still proceeds (matching the resource-level + * `tagExpressions`/table-seed tags, which are unaffected by this setting). + */ + #[Test] + public function cacheMissSkipsMemberTagExpressionsEvaluationForEmptyCollection(): void + { + $this->allowConditions(); + $this->responseCacheService->method('buildEntryIdentifier')->willReturn('entry-id'); + $this->responseCacheService->method('get')->willReturn(null); + $this->responseCacheService->expects(self::never())->method('evaluateStoreTagExpressions'); + $this->responseCacheService->expects(self::once()) + ->method('store') + ->with('entry-id', '{"fresh":true}', [], self::anything()); + + $operation = $this->createOperation(responseCacheSettings: ResponseCacheSettings::create([ + 'enabled' => true, + 'memberTagExpressions' => ["'author_' ~ object.getAuthor().getUid()"], + ])); + + $processorCalled = false; + $output = $this->resolve( + $this->processor('{"fresh":true}', $processorCalled), + $operation, + result: $this->createCollectionResponseStub([]) + ); + + self::assertSame('{"fresh":true}', $output); + } + + /** + * Mirrors `cacheMissDoesNotStoreWhenTagExpressionEvaluationFails()`: a single member's + * `memberTagExpressions` evaluation failure means the entry's invalidation contract can no + * longer be guaranteed for the whole response, so the entry is not stored at all - not even a + * partial tag set from the members that did evaluate successfully. + */ + #[Test] + public function cacheMissDoesNotStoreWhenAMemberTagExpressionEvaluationFails(): void + { + $this->allowConditions(); + $this->responseCacheService->method('buildEntryIdentifier')->willReturn('entry-id'); + $this->responseCacheService->method('get')->willReturn(null); + $this->responseCacheService->method('evaluateStoreTagExpressions')->willReturn(null); + $this->responseCacheService->expects(self::never())->method('store'); + + $operation = $this->createOperation(responseCacheSettings: ResponseCacheSettings::create([ + 'enabled' => true, + 'memberTagExpressions' => ['thisFunctionDoesNotExist()'], + ])); + + $processorCalled = false; + $output = $this->resolve( + $this->processor('{"fresh":true}', $processorCalled), + $operation, + result: $this->createMock(AbstractDomainObject::class) + ); + + self::assertSame('{"fresh":true}', $output); + self::assertTrue($processorCalled); + } + + /** + * A collection member that is not an `AbstractDomainObject` is skipped with a logged warning + * rather than evaluated against - it never reaches `evaluateStoreTagExpressions()` at all, and + * never blocks the other, valid member from being evaluated. + */ + #[Test] + public function cacheMissSkipsNonDomainObjectCollectionMemberWithLoggedWarning(): void + { + $this->allowConditions(); + $this->responseCacheService->method('buildEntryIdentifier')->willReturn('entry-id'); + $this->responseCacheService->method('get')->willReturn(null); + + $validMember = $this->createMock(AbstractDomainObject::class); + $this->responseCacheService->expects(self::once()) + ->method('evaluateStoreTagExpressions') + ->with(self::anything(), self::anything(), self::anything(), self::anything(), ['object' => $validMember]) + ->willReturn(['author_1']); + $this->responseCacheService->expects(self::once()) + ->method('store') + ->with('entry-id', '{"fresh":true}', ['author_1'], self::anything()); + + $logger = $this->createMock(LoggerInterface::class); + $logger->expects(self::once())->method('warning'); + + $operation = $this->createOperation(responseCacheSettings: ResponseCacheSettings::create([ + 'enabled' => true, + 'memberTagExpressions' => ["'author_' ~ object.getAuthor().getUid()"], + ])); + + $operationResponseCache = $this->createOperationResponseCache(); + $operationResponseCache->setLogger($logger); + + $processorCalled = false; + $response = new Response(); + $result = $this->createCollectionResponseStub(['not-a-domain-object', $validMember]); + $output = $operationResponseCache->resolve( + $operation, + [], + Request::create('https://example.com/_api/books', 'GET'), + $this->processor('{"fresh":true}', $processorCalled), + $response, + $result + ); + + self::assertSame('{"fresh":true}', $output); + } + + #[Test] + public function invalidationTagsFlushedAfterSuccessfulNonGetOperation(): void + { + $this->responseCacheService->method('buildEntryIdentifier')->willReturn(null); + $this->responseCacheService->method('isValidTag')->willReturn(true); + $this->responseCacheService->expects(self::once()) + ->method('flushByTags') + ->with(['tx_test_domain_model_recipe']); + $this->responseCacheService->expects(self::never())->method('store'); + + $operation = $this->createOperation( + cacheInvalidationSettings: CacheInvalidationSettings::create([ + 'tags' => ['tx_test_domain_model_recipe'], + ]), + isMethodGet: false + ); + + $processorCalled = false; + $response = new Response(); + $output = $this->resolve( + $this->processor('{"toggled":true}', $processorCalled, $response), + $operation, + $response + ); + + self::assertSame('{"toggled":true}', $output); + self::assertTrue($processorCalled); + } + + /** + * `flushInvalidationTagsAfterSuccessfulWrite()` reports the exact tag set it just passed to + * `ResponseCacheService::flushByTags()` to `ResponseCacheDebugHeaders::flushed()`. + */ + #[Test] + public function invalidationFlushDecoratesResponseWithFlushedTagsViaDebugHeadersCollaborator(): void + { + $this->responseCacheService->method('buildEntryIdentifier')->willReturn(null); + $this->responseCacheService->method('isValidTag')->willReturn(true); + $this->responseCacheDebugHeaders->expects(self::once()) + ->method('flushed') + ->with(self::anything(), ['tx_test_domain_model_recipe']); + + $operation = $this->createOperation( + cacheInvalidationSettings: CacheInvalidationSettings::create([ + 'tags' => ['tx_test_domain_model_recipe'], + ]), + isMethodGet: false + ); + + $processorCalled = false; + $response = new Response(); + $output = $this->resolve( + $this->processor('{"toggled":true}', $processorCalled, $response), + $operation, + $response + ); + + self::assertSame('{"toggled":true}', $output); + } + + /** + * `tags` are pure literals with no route substitution - a route-driven flush tag belongs in + * `tagExpressions` instead, via the `route` variable (see `ResponseCacheServiceTest` for + * coverage of the expression evaluating that variable). This only asserts the wiring: the + * exact `$route` given to `resolve()` reaches `evaluateFlushTagExpressions()` unchanged. + */ + #[Test] + public function routeIsForwardedToFlushTagExpressionEvaluation(): void + { + $this->responseCacheService->method('buildEntryIdentifier')->willReturn(null); + $this->responseCacheService->expects(self::once()) + ->method('evaluateFlushTagExpressions') + ->with(self::anything(), self::anything(), ['recipeId' => '7'], self::anything()) + ->willReturn([]); + + $operation = $this->createOperation( + cacheInvalidationSettings: CacheInvalidationSettings::create([ + 'tagExpressions' => ["'tx_test_domain_model_recipe_' ~ route['recipeId']"], + ]), + isMethodGet: false + ); + + $processorCalled = false; + $response = new Response(); + $output = $this->resolve( + $this->processor('{"toggled":true}', $processorCalled, $response), + $operation, + $response, + ['recipeId' => '7'] + ); + + self::assertSame('{"toggled":true}', $output); + } + + #[Test] + public function invalidationFlushMergesStaticAndExpressionTags(): void + { + $this->responseCacheService->method('buildEntryIdentifier')->willReturn(null); + $this->responseCacheService->method('isValidTag')->willReturn(true); + $this->responseCacheService->method('evaluateFlushTagExpressions')->willReturn(['fe_user_1']); + $this->responseCacheService->expects(self::once()) + ->method('flushByTags') + ->with(['tx_test_domain_model_recipe', 'fe_user_1']); + + $operation = $this->createOperation( + cacheInvalidationSettings: CacheInvalidationSettings::create([ + 'tags' => ['tx_test_domain_model_recipe'], + 'tagExpressions' => ["'fe_user_' ~ user.getUid()"], + ]), + isMethodGet: false + ); + + $processorCalled = false; + $response = new Response(); + $output = $this->resolve( + $this->processor('{"toggled":true}', $processorCalled, $response), + $operation, + $response + ); + + self::assertSame('{"toggled":true}', $output); + } + + /** + * `cacheInvalidation.tagExpressions` sees `object`, bound to the persisted entity the write + * operation returned - unlike `cache.tagExpressions`, which never has it (see + * `ResponseCacheServiceTest` for the plumbing this exercises the wiring of). + */ + #[Test] + public function invalidationFlushExposesWrittenEntityAsObjectVariable(): void + { + $this->responseCacheService->method('buildEntryIdentifier')->willReturn(null); + + $writtenEntity = $this->createMock(AbstractDomainObject::class); + $this->responseCacheService->expects(self::once()) + ->method('evaluateFlushTagExpressions') + ->with(self::anything(), self::anything(), self::anything(), self::anything(), ['object' => $writtenEntity]) + ->willReturn([]); + + $operation = $this->createOperation( + cacheInvalidationSettings: CacheInvalidationSettings::create([ + 'tagExpressions' => ["object != null ? 'tx_test_domain_model_recipe_' ~ object.getUid() : ''"], + ]), + isMethodGet: false + ); + + $processorCalled = false; + $response = new Response(); + $output = $this->resolve( + $this->processor('{"toggled":true}', $processorCalled, $response), + $operation, + $response, + result: $writtenEntity + ); + + self::assertSame('{"toggled":true}', $output); + } + + /** + * A `DELETE` handler (or any custom write handler returning nothing) leaves `$result` `null` - + * `cacheInvalidation.tagExpressions` still runs, with `object` bound to `null` rather than being + * skipped; the documented null-safe idiom (`object != null ? ... : ''`) is what a `tagExpressions` + * entry uses to handle this without erroring. + */ + #[Test] + public function invalidationFlushExposesNullObjectVariableWhenResultIsNull(): void + { + $this->responseCacheService->method('buildEntryIdentifier')->willReturn(null); + $this->responseCacheService->expects(self::once()) + ->method('evaluateFlushTagExpressions') + ->with(self::anything(), self::anything(), self::anything(), self::anything(), ['object' => null]) + ->willReturn([]); + + $operation = $this->createOperation( + cacheInvalidationSettings: CacheInvalidationSettings::create([ + 'tagExpressions' => ["object != null ? 'tx_test_domain_model_recipe_' ~ object.getUid() : ''"], + ]), + isMethodGet: false + ); + + $processorCalled = false; + $response = new Response(); + $output = $this->resolve( + $this->processor('{"deleted":true}', $processorCalled, $response), + $operation, + $response + ); + + self::assertSame('{"deleted":true}', $output); + } + + /** + * Fail-soft, unlike the store side above: `ResponseCacheService` already filters a failing (or + * empty-result) expression out of what it returns, logging its own warning - the write already + * succeeded, so the flush proceeds with whatever tags remain (here, only the static one) rather + * than being skipped entirely. + */ + #[Test] + public function invalidationFlushStillOccursWithStaticTagsWhenExpressionTagsAreEmpty(): void + { + $this->responseCacheService->method('buildEntryIdentifier')->willReturn(null); + $this->responseCacheService->method('isValidTag')->willReturn(true); + $this->responseCacheService->method('evaluateFlushTagExpressions')->willReturn([]); + $this->responseCacheService->expects(self::once()) + ->method('flushByTags') + ->with(['tx_test_domain_model_recipe']); + + $operation = $this->createOperation( + cacheInvalidationSettings: CacheInvalidationSettings::create([ + 'tags' => ['tx_test_domain_model_recipe'], + 'tagExpressions' => ['thisFunctionDoesNotExist()'], + ]), + isMethodGet: false + ); + + $processorCalled = false; + $response = new Response(); + $output = $this->resolve( + $this->processor('{"toggled":true}', $processorCalled, $response), + $operation, + $response + ); + + self::assertSame('{"toggled":true}', $output); + } + + #[Test] + public function invalidationTagsNotFlushedWhenProcessorThrows(): void + { + $this->responseCacheService->method('buildEntryIdentifier')->willReturn(null); + $this->responseCacheService->expects(self::never())->method('flushByTags'); + + $operation = $this->createOperation( + cacheInvalidationSettings: CacheInvalidationSettings::create(['tags' => ['some_tag']]), + isMethodGet: false + ); + + $this->expectException(\RuntimeException::class); + + $this->resolve( + static function (): string { + throw new \RuntimeException('write failed'); + }, + $operation + ); + } + + /** + * A resource-level `cacheInvalidation` block cascades onto every operation, including a `GET` + * one - but a `GET` operation never writes anything, so it must never act on inherited + * invalidation tags either, unlike a non-GET operation (see + * `invalidationTagsFlushedAfterSuccessfulNonGetOperation` above). + */ + #[Test] + public function getOperationWithInheritedInvalidationTagsNeverFlushes(): void + { + $this->responseCacheService->method('buildEntryIdentifier')->willReturn(null); + $this->responseCacheService->expects(self::never())->method('flushByTags'); + $this->responseCacheService->expects(self::never())->method('store'); + + $operation = $this->createOperation( + cacheInvalidationSettings: CacheInvalidationSettings::create([ + 'tags' => ['tx_test_domain_model_recipe'], + ]), + isMethodGet: true + ); + + $processorCalled = false; + $output = $this->resolve($this->processor('{"fresh":true}', $processorCalled), $operation); + + self::assertSame('{"fresh":true}', $output); + self::assertTrue($processorCalled); + } + + #[Test] + public function invalidTagIsSkippedAndLogged(): void + { + $this->allowConditions(); + $this->responseCacheService->method('buildEntryIdentifier')->willReturn('entry-id'); + $this->responseCacheService->method('get')->willReturn(null); + $this->responseCacheService->method('isValidTag')->willReturnCallback( + static fn(string $tag): bool => preg_match('/^[a-zA-Z0-9_%\\-&]{1,250}$/', $tag) === 1 + ); + $this->responseCacheService->expects(self::once()) + ->method('store') + ->with('entry-id', '{"fresh":true}', [], self::anything()); + + $logger = $this->createMock(LoggerInterface::class); + $logger->expects(self::exactly(2))->method('warning'); + + $operation = $this->createOperation(responseCacheSettings: ResponseCacheSettings::create([ + 'enabled' => true, + 'tags' => ['invalid tag with spaces', 'tag/with/slash'], + ])); + + $operationResponseCache = $this->createOperationResponseCache(); + $operationResponseCache->setLogger($logger); + + $processorCalled = false; + $response = new Response(); + $output = $operationResponseCache->resolve( + $operation, + [], + Request::create('https://example.com/_api/books', 'GET'), + $this->processor('{"fresh":true}', $processorCalled), + $response + ); + + self::assertSame('{"fresh":true}', $output); + } + + #[Test] + public function emptyCacheSettingsResultInNoFlushCalls(): void + { + $this->allowConditions(); + $this->responseCacheService->method('buildEntryIdentifier')->willReturn('entry-id'); + $this->responseCacheService->method('get')->willReturn(null); + $this->responseCacheService->expects(self::never())->method('flushByTags'); + + $processorCalled = false; + $output = $this->resolve($this->processor('{"fresh":true}', $processorCalled)); + + self::assertSame('{"fresh":true}', $output); + self::assertTrue($processorCalled); + } + + #[Test] + public function securedCollectionOperationServesHitAndChecksAccessOnEveryResolve(): void + { + $this->allowConditions(); + $this->responseCacheService->method('buildEntryIdentifier')->willReturn('entry-id'); + $this->responseCacheService->method('get')->willReturn('{"cached":true}'); + $this->operationAccessChecker->expects(self::exactly(2))->method('isGranted')->willReturn(true); + + $operation = $this->createSecuredCollectionOperation(); + $processorCalled = false; + + self::assertSame( + '{"cached":true}', + $this->resolve($this->processor('{"fresh":true}', $processorCalled), $operation) + ); + self::assertSame( + '{"cached":true}', + $this->resolve($this->processor('{"fresh":true}', $processorCalled), $operation) + ); + self::assertFalse($processorCalled); + } + + /** + * `OperationNotAllowedException` translates its title/description via TYPO3's `LanguageService` + * - `AbstractException::translate()` falls back to the raw key when that fails, so this needs + * no TYPO3 translation machinery faked at all, and stays correct across the whole supported + * TYPO3/PHPUnit version range. + */ + #[Test] + public function securedCollectionOperationDeniedThrowsAndNeverTouchesCache(): void + { + $this->responseCacheService->method('buildEntryIdentifier')->willReturn('entry-id'); + $this->responseCacheService->expects(self::never())->method('get'); + $this->responseCacheService->expects(self::never())->method('store'); + $this->operationAccessChecker->method('isGranted')->willReturn(false); + + $operation = $this->createSecuredCollectionOperation(); + $processorCalled = false; + + $this->expectException(OperationNotAllowedException::class); + + $this->resolve($this->processor('{"fresh":true}', $processorCalled), $operation); + } + + #[Test] + public function securedItemOperationRetriesGrantCheckWithLoadedEntityWhenExpressionNeedsObject(): void + { + $this->allowConditions(); + $this->responseCacheService->method('buildEntryIdentifier')->willReturn('entry-id'); + $this->responseCacheService->method('get')->willReturn(null); + $this->responseCacheService->expects(self::once())->method('store'); + + $entity = $this->createMock(AbstractDomainObject::class); + $operation = $this->createSecuredItemOperation(); + + $this->operationAccessChecker->expects(self::exactly(2)) + ->method('isGranted') + ->willReturnCallback(function (OperationInterface $calledOperation, array $variables = []) use ($entity) { + if ($variables === []) { + throw new \RuntimeException('Variable "object" is not valid.'); + } + + self::assertSame($entity, $variables['object'] ?? null); + + return true; + }); + + $logger = $this->createMock(LoggerInterface::class); + $logger->expects(self::once())->method('warning'); + + $operationResponseCache = $this->createOperationResponseCacheWithStubbedEntityLoad($entity); + $operationResponseCache->setLogger($logger); + + $processorCalled = false; + $response = new Response(); + $output = $operationResponseCache->resolve( + $operation, + ['id' => '5'], + Request::create('https://example.com/_api/books/5', 'GET'), + $this->processor('{"fresh":true}', $processorCalled), + $response + ); + + self::assertSame('{"fresh":true}', $output); + self::assertTrue($processorCalled); + } + + #[Test] + public function securedItemOperationDelegatesToProcessorWhenEntityCannotBeLoaded(): void + { + $operation = $this->createSecuredItemOperation(); + + $this->responseCacheService->method('buildEntryIdentifier')->willReturn('entry-id'); + $this->responseCacheService->expects(self::never())->method('get'); + $this->responseCacheService->expects(self::never())->method('store'); + $this->operationAccessChecker->method('isGranted')->willThrowException( + new \RuntimeException('Variable "object" is not valid.') + ); + $this->responseCacheDebugHeaders->expects(self::once())->method('lookupOutcome')->with(self::anything(), 'miss'); + + $operationResponseCache = $this->createOperationResponseCacheWithStubbedEntityLoad(null); + + $processorCalled = false; + $response = new Response(); + $output = $operationResponseCache->resolve( + $operation, + ['id' => '404'], + Request::create('https://example.com/_api/books/404', 'GET'), + $this->processor('{"fresh":true}', $processorCalled, $response), + $response + ); + + self::assertSame('{"fresh":true}', $output); + self::assertTrue($processorCalled); + } + + private function allowConditions(bool $read = true, bool $write = true): void + { + $this->responseCacheService->method('isReadAllowed')->willReturn($read); + $this->responseCacheService->method('isWriteAllowed')->willReturn($write); + } + + /** + * Builds a `resolve()` processor closure that records whether it ran and, + * when `$forcedStatusCode` is given, mutates the by-reference `$response` + * the same way a real operation handler would. + */ + private function processor( + string $cannedOutput, + bool &$called, + ?ResponseInterface &$response = null, + ?int $forcedStatusCode = null + ): callable { + return function () use ($cannedOutput, &$called, &$response, $forcedStatusCode): string { + $called = true; + if ($forcedStatusCode !== null && $response !== null) { + $response = $response->withStatus($forcedStatusCode); + } + + return $cannedOutput; + }; + } + + /** + * `$result` stands in for what `AbstractDispatcher::processOperation()`'s closure would have + * written by the time `OperationResponseCache::resolve()` reaches the store/flush step - the + * fixture processors above never touch it themselves, so tests exercising `object` simply give + * the entity they want to see up front. + */ + private function resolve( + callable $processor, + ?OperationInterface $operation = null, + ?ResponseInterface &$response = null, + array $route = [], + mixed $result = null + ): string { + $response ??= new Response(); + + return $this->createOperationResponseCache()->resolve( + $operation ?? $this->createOperation(), + $route, + Request::create('https://example.com/_api/books', 'GET'), + $processor, + $response, + $result + ); + } + + private function createOperationResponseCache(): OperationResponseCache + { + return new OperationResponseCache( + $this->responseCacheService, + $this->cacheTagCollector, + $this->operationAccessChecker, + $this->dataMapper, + $this->responseCacheDebugHeaders + ); + } + + /** + * A minimal `AbstractCollectionResponse` double whose `getMembers()` returns the given array + * directly, bypassing pagination/query execution entirely - unit tests exercising + * `memberTagExpressions` only need control over the member set, not a working Extbase query. + * + * @param mixed[] $members + */ + private function createCollectionResponseStub(array $members): AbstractCollectionResponse + { + return new class ($this->createMock(CollectionOperation::class), Request::create('https://example.com/_api/books', 'GET'), $this->createMock(QueryInterface::class), $members) extends AbstractCollectionResponse { + /** + * @param mixed[] $stubbedMembers + */ + public function __construct( + CollectionOperation $operation, + Request $request, + QueryInterface $query, + private readonly array $stubbedMembers + ) { + parent::__construct($operation, $request, $query); + } + + public static function getOpenApiSchema(string $membersReference): Schema + { + throw new \LogicException('Not used by this test double.'); + } + + public function getMembers(): array + { + return $this->stubbedMembers; + } + }; + } + + /** + * Builds an `OperationResponseCache` with `loadItemOperationObject()` stubbed, + * since exercising the real `CommonRepository::getInstanceForOperation()` static + * factory needs a booted TYPO3 persistence layer unavailable to a unit test. + */ + private function createOperationResponseCacheWithStubbedEntityLoad( + ?AbstractDomainObject $entity + ): MockObject&OperationResponseCache { + $operationResponseCache = $this->getMockBuilder(OperationResponseCache::class) + ->setConstructorArgs([ + $this->responseCacheService, + $this->cacheTagCollector, + $this->operationAccessChecker, + $this->dataMapper, + $this->responseCacheDebugHeaders, + ]) + ->onlyMethods(['loadItemOperationObject']) + ->getMock(); + $operationResponseCache->method('loadItemOperationObject')->willReturn($entity); + + return $operationResponseCache; + } + + /** + * Builds an `OperationInterface` mock exposing real cache settings, since + * `resolve()` reads the lifetime from it on the store path (the mocked + * `responseCacheService` in this test does not enforce the real + * disabled-cache-settings invariant). `getSecurity()` defaults to an empty + * string (the mock default for an unconfigured `string` return type), so + * the security gate never engages for this operation. `isMethodGet()` defaults + * to `true` since most callers exercise the cacheable `GET` path; pass `false` + * to build a write operation for the invalidation-flush tests. + * + * `getApiResource()->getEntity()` is left unstubbed by default, which the mock + * resolves to `''` - `is_subclass_of('', AbstractDomainObject::class)` is `false`, + * so table tag seeding is a no-op unless `$entityClass` names an actual resource. + * `$isCollectionOperation` builds a `CollectionOperation` mock instead of a plain + * `OperationInterface` one - needed for `seedResourceTableTag()`'s + * `$operation instanceof CollectionOperation` scope-tag check, since the default + * `OperationInterface` mock is never an instance of it. + */ + private function createOperation( + string $entityClass = '', + ?ResponseCacheSettings $responseCacheSettings = null, + ?CacheInvalidationSettings $cacheInvalidationSettings = null, + bool $isMethodGet = true, + bool $isCollectionOperation = false + ): OperationInterface { + $operation = $isCollectionOperation + ? $this->createMock(CollectionOperation::class) + : $this->createMock(OperationInterface::class); + $operation->method('isMethodGet')->willReturn($isMethodGet); + $operation->method('getResponseCacheSettings')->willReturn( + $responseCacheSettings ?? ResponseCacheSettings::create(['enabled' => true]) + ); + $operation->method('getCacheInvalidationSettings')->willReturn( + $cacheInvalidationSettings ?? CacheInvalidationSettings::create() + ); + + if ($entityClass !== '') { + $apiResource = $this->createMock(ApiResource::class); + $apiResource->method('getEntity')->willReturn($entityClass); + $operation->method('getApiResource')->willReturn($apiResource); + } + + return $operation; + } + + private function createSecuredCollectionOperation(string $security = '1 == 1'): CollectionOperation&MockObject + { + $operation = $this->createMock(CollectionOperation::class); + $operation->method('getSecurity')->willReturn($security); + $operation->method('getPath')->willReturn('/books'); + $operation->method('isMethodGet')->willReturn(true); + $operation->method('getResponseCacheSettings')->willReturn( + ResponseCacheSettings::create(['enabled' => true]) + ); + + return $operation; + } + + private function createSecuredItemOperation(string $security = 'object.isPublic'): ItemOperation&MockObject + { + $apiResource = $this->createMock(ApiResource::class); + $apiResource->method('getEntity')->willReturn('Vendor\\SiteExtension\\Domain\\Model\\Book'); + + $operation = $this->createMock(ItemOperation::class); + $operation->method('getSecurity')->willReturn($security); + $operation->method('getKey')->willReturn('get'); + $operation->method('getPath')->willReturn('/books/{id}'); + $operation->method('getApiResource')->willReturn($apiResource); + $operation->method('isMethodGet')->willReturn(true); + $operation->method('getResponseCacheSettings')->willReturn( + ResponseCacheSettings::create(['enabled' => true]) + ); + + return $operation; + } +} diff --git a/Tests/Unit/Service/ResponseCacheDebugHeadersTest.php b/Tests/Unit/Service/ResponseCacheDebugHeadersTest.php new file mode 100644 index 0000000..92848ce --- /dev/null +++ b/Tests/Unit/Service/ResponseCacheDebugHeadersTest.php @@ -0,0 +1,147 @@ +isDevelopment()` gate is stubbed via a protected-method + * override on an anonymous subclass (see `createDebugHeaders()`) rather than manipulating + * TYPO3's global `Environment` state - the same testability pattern already used for + * `OperationResponseCache::loadItemOperationObject()`. + */ +class ResponseCacheDebugHeadersTest extends UnitTestCase +{ + #[Test] + public function gateClosedLeavesHitResponseUnchanged(): void + { + $debugHeaders = $this->createDebugHeaders(isDevelopment: false); + $response = new Response(); + + $debugHeaders->hit($response, 'entry-id'); + + self::assertSame([], $response->getHeaders()); + } + + #[Test] + public function gateClosedLeavesLookupOutcomeResponseUnchanged(): void + { + $debugHeaders = $this->createDebugHeaders(isDevelopment: false); + $response = new Response(); + + $debugHeaders->lookupOutcome($response, 'miss'); + + self::assertSame([], $response->getHeaders()); + } + + #[Test] + public function gateClosedLeavesStoredResponseUnchanged(): void + { + $debugHeaders = $this->createDebugHeaders(isDevelopment: false); + $response = new Response(); + + $debugHeaders->stored($response, 'entry-id', ['tag-a']); + + self::assertSame([], $response->getHeaders()); + } + + #[Test] + public function gateClosedLeavesFlushedResponseUnchanged(): void + { + $debugHeaders = $this->createDebugHeaders(isDevelopment: false); + $response = new Response(); + + $debugHeaders->flushed($response, ['tag-a']); + + self::assertSame([], $response->getHeaders()); + } + + #[Test] + public function hitSetsCacheAndIdentifierHeaders(): void + { + $debugHeaders = $this->createDebugHeaders(isDevelopment: true); + $response = new Response(); + + $debugHeaders->hit($response, 'entry-id'); + + self::assertSame('hit', $response->getHeaderLine('X-T3api-Cache')); + self::assertSame('entry-id', $response->getHeaderLine('X-T3api-Cache-Identifier')); + } + + #[Test] + public function lookupOutcomeSetsCacheHeaderToGivenOutcome(): void + { + $debugHeaders = $this->createDebugHeaders(isDevelopment: true); + $response = new Response(); + + $debugHeaders->lookupOutcome($response, 'bypass'); + + self::assertSame('bypass', $response->getHeaderLine('X-T3api-Cache')); + self::assertFalse($response->hasHeader('X-T3api-Cache-Identifier')); + } + + #[Test] + public function storedSetsIdentifierAndTagsHeaders(): void + { + $debugHeaders = $this->createDebugHeaders(isDevelopment: true); + $response = new Response(); + + $debugHeaders->stored($response, 'entry-id', ['tag-a', 'tag-b']); + + self::assertSame('entry-id', $response->getHeaderLine('X-T3api-Cache-Identifier')); + self::assertSame('tag-a,tag-b', $response->getHeaderLine('X-T3api-Cache-Tags')); + } + + #[Test] + public function flushedSetsFlushedTagsHeader(): void + { + $debugHeaders = $this->createDebugHeaders(isDevelopment: true); + $response = new Response(); + + $debugHeaders->flushed($response, ['tag-a', 'tag-b']); + + self::assertSame('tag-a,tag-b', $response->getHeaderLine('X-T3api-Cache-Flushed-Tags')); + } + + #[Test] + public function flushedOmitsHeaderWhenTagsAreEmpty(): void + { + $debugHeaders = $this->createDebugHeaders(isDevelopment: true); + $response = new Response(); + + $debugHeaders->flushed($response, []); + + self::assertFalse($response->hasHeader('X-T3api-Cache-Flushed-Tags')); + } + + #[Test] + public function nullResponseIsANoOpForEveryMethod(): void + { + $debugHeaders = $this->createDebugHeaders(isDevelopment: true); + $response = null; + + $debugHeaders->hit($response, 'entry-id'); + $debugHeaders->lookupOutcome($response, 'miss'); + $debugHeaders->stored($response, 'entry-id', ['tag-a']); + $debugHeaders->flushed($response, ['tag-a']); + + self::assertNull($response); + } + + private function createDebugHeaders(bool $isDevelopment): ResponseCacheDebugHeaders + { + return new class ($isDevelopment) extends ResponseCacheDebugHeaders { + public function __construct(private readonly bool $isDevelopment) {} + + protected function isDevelopmentContext(): bool + { + return $this->isDevelopment; + } + }; + } +} diff --git a/Tests/Unit/Service/ResponseCacheServiceTest.php b/Tests/Unit/Service/ResponseCacheServiceTest.php new file mode 100644 index 0000000..77cf341 --- /dev/null +++ b/Tests/Unit/Service/ResponseCacheServiceTest.php @@ -0,0 +1,821 @@ +cache = $this->createMock(FrontendInterface::class); + $GLOBALS['TYPO3_REQUEST'] = (new ServerRequest('https://example.com/_api/books')) + ->withAttribute('site', new Site('site-one', 1, ['base' => 'https://example.com/', 'languages' => []])) + ->withAttribute('language', new SiteLanguage(0, 'en_US.UTF-8', new Uri('https://example.com/'), [])); + } + + protected function tearDown(): void + { + unset($GLOBALS['TYPO3_REQUEST']); + parent::tearDown(); + } + + #[Test] + public function returnsNullForNonGetRequests(): void + { + $service = new ResponseCacheService($this->cache, new Context()); + $request = Request::create('https://example.com/_api/books', 'POST'); + + self::assertNull($service->buildEntryIdentifier($this->createOperation([]), [], $request)); + } + + #[Test] + public function returnsNullWhenCacheIsDisabled(): void + { + $service = new ResponseCacheService($this->cache, new Context()); + $request = Request::create('https://example.com/_api/books', 'GET'); + + self::assertNull($service->buildEntryIdentifier($this->createOperation(null), [], $request)); + } + + #[Test] + public function falsyFilterValueProducesDistinctIdentifier(): void + { + $service = new ResponseCacheService($this->cache, new Context()); + $operation = $this->createOperation([]); + + self::assertNotSame( + $service->buildEntryIdentifier($operation, [], Request::create('https://example.com/_api/books?title=0', 'GET')), + $service->buildEntryIdentifier($operation, [], Request::create('https://example.com/_api/books', 'GET')) + ); + } + + #[Test] + public function unknownParametersDoNotInfluenceTheIdentifier(): void + { + $service = new ResponseCacheService($this->cache, new Context()); + $operation = $this->createOperation([]); + + $withUnknownParam = $service->buildEntryIdentifier( + $operation, + [], + Request::create('https://example.com/_api/books?utm_source=foo', 'GET') + ); + $withoutParams = $service->buildEntryIdentifier( + $operation, + [], + Request::create('https://example.com/_api/books', 'GET') + ); + + self::assertNotNull($withoutParams); + self::assertSame($withoutParams, $withUnknownParam); + } + + #[Test] + public function paginationAndFilterParametersInfluenceTheIdentifier(): void + { + $service = new ResponseCacheService($this->cache, new Context()); + $operation = $this->createOperation([]); + + $pageOne = $service->buildEntryIdentifier( + $operation, + [], + Request::create('https://example.com/_api/books?page=1', 'GET') + ); + $pageTwo = $service->buildEntryIdentifier( + $operation, + [], + Request::create('https://example.com/_api/books?page=2', 'GET') + ); + $filtered = $service->buildEntryIdentifier( + $operation, + [], + Request::create('https://example.com/_api/books?title=foo', 'GET') + ); + + self::assertNotSame($pageOne, $pageTwo); + self::assertNotSame($pageOne, $filtered); + } + + #[Test] + public function pathInfluencesTheIdentifier(): void + { + $service = new ResponseCacheService($this->cache, new Context()); + $operation = $this->createOperation([]); + + self::assertNotSame( + $service->buildEntryIdentifier($operation, [], Request::create('https://example.com/_api/books', 'GET')), + $service->buildEntryIdentifier($operation, [], Request::create('https://example.com/_api/novels', 'GET')) + ); + } + + #[Test] + public function siteIdentifierInfluencesTheIdentifier(): void + { + $service = new ResponseCacheService($this->cache, new Context()); + $operation = $this->createOperation([]); + $request = Request::create('https://example.com/_api/books', 'GET'); + + $siteOneIdentifier = $service->buildEntryIdentifier($operation, [], $request); + + $GLOBALS['TYPO3_REQUEST'] = $GLOBALS['TYPO3_REQUEST']->withAttribute( + 'site', + new Site('site-two', 2, ['base' => 'https://other.example.com/', 'languages' => []]) + ); + + self::assertNotSame($siteOneIdentifier, $service->buildEntryIdentifier($operation, [], $request)); + } + + #[Test] + public function languageIdInfluencesTheIdentifier(): void + { + $service = new ResponseCacheService($this->cache, new Context()); + $operation = $this->createOperation([]); + $request = Request::create('https://example.com/_api/books', 'GET'); + + $defaultLanguageIdentifier = $service->buildEntryIdentifier($operation, [], $request); + + $GLOBALS['TYPO3_REQUEST'] = $GLOBALS['TYPO3_REQUEST']->withAttribute( + 'language', + new SiteLanguage(1, 'de_DE.UTF-8', new Uri('https://example.com/de/'), []) + ); + + self::assertNotSame($defaultLanguageIdentifier, $service->buildEntryIdentifier($operation, [], $request)); + } + + /** + * `context` (a `getPropertyFromAspect`-capable TYPO3 `Context`) is a provider-level + * expression variable, not one `getConditionVariables()` adds itself - the double resolver + * is therefore given it directly, the same way the real `T3apiCoreProvider` would. + */ + #[Test] + public function aspectBasedIdentifierExpressionVariesByContextAspectValue(): void + { + $operation = $this->createOperation([ + 'identifierExpressions' => ["context.getPropertyFromAspect('frontend.user', 'isLoggedIn', '')"], + ]); + $request = Request::create('https://example.com/_api/books', 'GET'); + + $loggedOutContext = new Context(); + $this->registerPlainSymfonyExpressionResolver(['context' => $loggedOutContext]); + $loggedOutIdentifier = (new ResponseCacheService($this->cache, $loggedOutContext)) + ->buildEntryIdentifier($operation, [], $request); + + // `UserAspect` is declared `final` and cannot be doubled - build a real instance backed + // by a mocked user authentication object instead, so it reports a different value. + $loggedInUser = $this->createMock(AbstractUserAuthentication::class); + $loggedInUser->userid_column = 'uid'; + $loggedInUser->user = ['uid' => 1]; + + $loggedInContext = new Context(); + $loggedInContext->setAspect('frontend.user', new UserAspect($loggedInUser)); + $this->registerPlainSymfonyExpressionResolver(['context' => $loggedInContext]); + $loggedInIdentifier = (new ResponseCacheService($this->cache, $loggedInContext)) + ->buildEntryIdentifier($operation, [], $request); + + self::assertNotSame($loggedOutIdentifier, $loggedInIdentifier); + } + + #[Test] + public function identifierExpressionMakesRequestsWithDifferentHeaderValuesGetDifferentIdentifiers(): void + { + $this->registerPlainSymfonyExpressionResolver(); + $service = new ResponseCacheService($this->cache, new Context()); + $operation = $this->createOperation([ + 'identifierExpressions' => ["request.headers.get('X-App-Version')"], + ]); + + $requestWithVersionOne = Request::create('https://example.com/_api/books', 'GET'); + $requestWithVersionOne->headers->set('X-App-Version', '1'); + $requestWithVersionTwo = Request::create('https://example.com/_api/books', 'GET'); + $requestWithVersionTwo->headers->set('X-App-Version', '2'); + + self::assertNotSame( + $service->buildEntryIdentifier($operation, [], $requestWithVersionOne), + $service->buildEntryIdentifier($operation, [], $requestWithVersionTwo) + ); + } + + #[Test] + public function identifierExpressionProducesSameIdentifierForSameHeaderValue(): void + { + $this->registerPlainSymfonyExpressionResolver(); + $service = new ResponseCacheService($this->cache, new Context()); + $operation = $this->createOperation([ + 'identifierExpressions' => ["request.headers.get('X-App-Version')"], + ]); + + $firstRequest = Request::create('https://example.com/_api/books', 'GET'); + $firstRequest->headers->set('X-App-Version', '1'); + $secondRequest = Request::create('https://example.com/_api/books', 'GET'); + $secondRequest->headers->set('X-App-Version', '1'); + + self::assertSame( + $service->buildEntryIdentifier($operation, [], $firstRequest), + $service->buildEntryIdentifier($operation, [], $secondRequest) + ); + } + + #[Test] + public function twoIdentifierExpressionsBothInfluenceTheIdentifier(): void + { + $this->registerPlainSymfonyExpressionResolver(); + $service = new ResponseCacheService($this->cache, new Context()); + $operation = $this->createOperation([ + 'identifierExpressions' => [ + "request.headers.get('X-App-Version')", + "request.headers.get('X-Tenant')", + ], + ]); + + $baseline = Request::create('https://example.com/_api/books', 'GET'); + $baseline->headers->set('X-App-Version', '1'); + $baseline->headers->set('X-Tenant', 'acme'); + + $differentSecondExpressionOnly = Request::create('https://example.com/_api/books', 'GET'); + $differentSecondExpressionOnly->headers->set('X-App-Version', '1'); + $differentSecondExpressionOnly->headers->set('X-Tenant', 'other'); + + self::assertNotSame( + $service->buildEntryIdentifier($operation, [], $baseline), + $service->buildEntryIdentifier($operation, [], $differentSecondExpressionOnly) + ); + } + + #[Test] + public function identifierExpressionMakesRequestsWithDifferentRouteParametersGetDifferentIdentifiers(): void + { + $this->registerPlainSymfonyExpressionResolver(); + $service = new ResponseCacheService($this->cache, new Context()); + $operation = $this->createOperation([ + 'identifierExpressions' => ["route['id']"], + ]); + $request = Request::create('https://example.com/_api/books/5', 'GET'); + + self::assertNotSame( + $service->buildEntryIdentifier($operation, ['id' => '5'], $request), + $service->buildEntryIdentifier($operation, ['id' => '6'], $request) + ); + } + + #[Test] + public function emptyIdentifierExpressionsArrayLeavesIdentifierUnaffected(): void + { + $service = new ResponseCacheService($this->cache, new Context()); + $request = Request::create('https://example.com/_api/books', 'GET'); + + self::assertSame( + $service->buildEntryIdentifier($this->createOperation([]), [], $request), + $service->buildEntryIdentifier($this->createOperation(['identifierExpressions' => []]), [], $request) + ); + } + + #[Test] + public function brokenIdentifierExpressionFailsClosedAndLogsError(): void + { + $this->registerPlainSymfonyExpressionResolver(); + $logger = $this->createMock(LoggerInterface::class); + $logger->expects(self::once())->method('error'); + + $service = new ResponseCacheService($this->cache, new Context()); + $service->setLogger($logger); + $request = Request::create('https://example.com/_api/books', 'GET'); + $operation = $this->createOperation([ + 'identifierExpressions' => ['thisFunctionDoesNotExist()'], + ]); + + self::assertNull($service->buildEntryIdentifier($operation, [], $request)); + } + + /** + * The first expression evaluates fine; the second fails - the whole identifier must still + * fail closed, not just fall back to the part that succeeded. + */ + #[Test] + public function secondIdentifierExpressionFailingFailsClosedAndLogsError(): void + { + $this->registerPlainSymfonyExpressionResolver(); + $logger = $this->createMock(LoggerInterface::class); + $logger->expects(self::once())->method('error'); + + $service = new ResponseCacheService($this->cache, new Context()); + $service->setLogger($logger); + $request = Request::create('https://example.com/_api/books', 'GET'); + $operation = $this->createOperation([ + 'identifierExpressions' => [ + "request.headers.get('X-App-Version')", + 'thisFunctionDoesNotExist()', + ], + ]); + + self::assertNull($service->buildEntryIdentifier($operation, [], $request)); + } + + #[Test] + public function getReturnsNullOnCacheBackendFailure(): void + { + $this->cache->method('get')->willThrowException(new \RuntimeException('backend down')); + $service = new ResponseCacheService($this->cache, new Context()); + + self::assertNull($service->get('abc')); + } + + #[Test] + public function storeSwallowsCacheBackendFailure(): void + { + $this->cache->method('set')->willThrowException(new \RuntimeException('backend down')); + $service = new ResponseCacheService($this->cache, new Context()); + + $service->store('abc', '{}', [], 60); + $this->addToAssertionCount(1); + } + + #[Test] + public function clearCachePostProcFlushesEverythingOnCacheCmdAll(): void + { + $this->cache->expects(self::once())->method('flush'); + (new ResponseCacheService($this->cache, new Context()))->clearCachePostProc(['cacheCmd' => 'all']); + } + + #[Test] + public function clearCachePostProcFlushesByGivenTags(): void + { + $this->cache->expects(self::once())->method('flushByTags')->with(['tx_foo', 'tx_foo_5']); + (new ResponseCacheService($this->cache, new Context())) + ->clearCachePostProc(['tags' => ['tx_foo' => 1, 'tx_foo_5' => 1]]); + } + + #[Test] + public function returnsNullWhenSecuredFilterParameterIsPresentInRequest(): void + { + $service = new ResponseCacheService($this->cache, new Context()); + $request = Request::create('https://example.com/_api/books?title=foo', 'GET'); + $operation = $this->createOperation([], filters: [$this->createSecuredTitleFilter()]); + + self::assertNull($service->buildEntryIdentifier($operation, [], $request)); + } + + #[Test] + public function buildsIdentifierWhenSecuredFilterParameterIsAbsentFromRequest(): void + { + $service = new ResponseCacheService($this->cache, new Context()); + $request = Request::create('https://example.com/_api/books', 'GET'); + $operation = $this->createOperation([], filters: [$this->createSecuredTitleFilter()]); + + self::assertNotNull($service->buildEntryIdentifier($operation, [], $request)); + } + + #[Test] + public function emptyConditionsAllowBothReadAndWrite(): void + { + $service = new ResponseCacheService($this->cache, new Context()); + $request = Request::create('https://example.com/_api/books', 'GET'); + $operation = $this->createOperation([]); + + self::assertTrue($service->isReadAllowed($operation, [], $request)); + self::assertTrue($service->isWriteAllowed($operation, [], $request)); + } + + #[Test] + public function isReadAllowedReturnsFalseWhenReadConditionEvaluatesToFalse(): void + { + $this->registerPlainSymfonyExpressionResolver(); + $service = new ResponseCacheService($this->cache, new Context()); + $request = Request::create('https://example.com/_api/books', 'GET'); + $operation = $this->createOperation(['readCondition' => 'false']); + + self::assertFalse($service->isReadAllowed($operation, [], $request)); + } + + #[Test] + public function isWriteAllowedReturnsFalseWhenWriteConditionEvaluatesToFalse(): void + { + $this->registerPlainSymfonyExpressionResolver(); + $service = new ResponseCacheService($this->cache, new Context()); + $request = Request::create('https://example.com/_api/books', 'GET'); + $operation = $this->createOperation(['writeCondition' => 'false']); + + self::assertFalse($service->isWriteAllowed($operation, [], $request)); + } + + #[Test] + public function readConditionCanInspectTheSymfonyRequest(): void + { + $this->registerPlainSymfonyExpressionResolver(); + $service = new ResponseCacheService($this->cache, new Context()); + $operation = $this->createOperation([ + 'readCondition' => "request.query.get('refresh') == null", + ]); + + self::assertFalse( + $service->isReadAllowed($operation, [], Request::create('https://example.com/_api/books?refresh=1', 'GET')) + ); + self::assertTrue( + $service->isReadAllowed($operation, [], Request::create('https://example.com/_api/books', 'GET')) + ); + } + + #[Test] + public function readConditionCanInspectTheMatchedRouteParameter(): void + { + $this->registerPlainSymfonyExpressionResolver(); + $service = new ResponseCacheService($this->cache, new Context()); + $operation = $this->createOperation([ + 'readCondition' => "route['id'] == '5'", + ]); + $request = Request::create('https://example.com/_api/books/5', 'GET'); + + self::assertTrue($service->isReadAllowed($operation, ['id' => '5'], $request)); + self::assertFalse($service->isReadAllowed($operation, ['id' => '6'], $request)); + } + + /** + * The `route` variable never carries `_route` - its value is the spl_object_hash-based route + * name, nondeterministic across processes, so expressions using it would break identifier + * sharing. All other matched parameters pass through untouched. Exercised directly against + * the protected `getConditionVariables()` builder rather than through an expression, since + * the `in`/`not in` operators test array *value* membership, not key membership, and are + * therefore unsuitable for asserting a key is absent. + */ + #[Test] + public function routeVariableExcludesRouteNameKey(): void + { + $service = new ResponseCacheService($this->cache, new Context()); + $reflectionMethod = new \ReflectionMethod($service, 'getConditionVariables'); + $reflectionMethod->setAccessible(true); + + $variables = $reflectionMethod->invoke( + $service, + $this->createOperation([]), + ['id' => '5', '_route' => 'books_get'], + Request::create('https://example.com/_api/books/5', 'GET') + ); + + self::assertSame(['id' => '5'], $variables['route']); + } + + #[Test] + public function brokenConditionFailsClosedAndLogsError(): void + { + $this->registerPlainSymfonyExpressionResolver(); + $logger = $this->createMock(LoggerInterface::class); + $logger->expects(self::once())->method('error'); + + $service = new ResponseCacheService($this->cache, new Context()); + $service->setLogger($logger); + $request = Request::create('https://example.com/_api/books', 'GET'); + $operation = $this->createOperation([ + 'readCondition' => 'thisFunctionDoesNotExist()', + ]); + + self::assertFalse($service->isReadAllowed($operation, [], $request)); + } + + /** + * Mirrors `aspectBasedIdentifierExpressionVariesByContextAspectValue()` above: the same + * Context-aspect-driven expression that produces an empty string for an anonymous visitor and + * a per-user tag for a logged-in one - the documented idiom for conditional tagging (see + * `cache.tagExpressions`). + */ + #[Test] + public function evaluateStoreTagExpressionsSkipsEmptyResultAndKeepsResolvedTag(): void + { + $this->cache->method('isValidTag')->willReturn(true); + $operation = $this->createOperation([]); + $request = Request::create('https://example.com/_api/books', 'GET'); + $tagExpressions = [ + "context.getPropertyFromAspect('frontend.user', 'isLoggedIn', '') " + . "? 'fe_user_' ~ context.getPropertyFromAspect('frontend.user', 'id', '') : ''", + ]; + + $loggedOutContext = new Context(); + $this->registerPlainSymfonyExpressionResolver(['context' => $loggedOutContext]); + + self::assertSame( + [], + (new ResponseCacheService($this->cache, $loggedOutContext)) + ->evaluateStoreTagExpressions($tagExpressions, $operation, [], $request) + ); + + $loggedInUser = $this->createMock(AbstractUserAuthentication::class); + $loggedInUser->userid_column = 'uid'; + $loggedInUser->user = ['uid' => 1]; + + $loggedInContext = new Context(); + $loggedInContext->setAspect('frontend.user', new UserAspect($loggedInUser)); + $this->registerPlainSymfonyExpressionResolver(['context' => $loggedInContext]); + + self::assertSame( + ['fe_user_1'], + (new ResponseCacheService($this->cache, $loggedInContext)) + ->evaluateStoreTagExpressions($tagExpressions, $operation, [], $request) + ); + } + + #[Test] + public function evaluateStoreTagExpressionsCanUseMatchedRouteParameter(): void + { + $this->registerPlainSymfonyExpressionResolver(); + $this->cache->method('isValidTag')->willReturn(true); + $service = new ResponseCacheService($this->cache, new Context()); + + self::assertSame( + ['tx_myext_domain_model_recipe_42'], + $service->evaluateStoreTagExpressions( + ["'tx_myext_domain_model_recipe_' ~ route['recipeId']"], + $this->createOperation([]), + ['recipeId' => '42', '_route' => 'recipes_favorite'], + Request::create('https://example.com/_api/recipes/42/favorite', 'POST') + ) + ); + } + + /** + * `object` is not part of the standard tag-expression variable set - `OperationResponseCache` + * adds it explicitly, via `$additionalVariables`, only for `memberTagExpressions` (see + * `evaluateMemberTagExpressions()`). This exercises the plumbing directly: an `object` given via + * `$additionalVariables` is visible to the expression. + */ + #[Test] + public function evaluateStoreTagExpressionsMakesAdditionalVariablesAvailableToTheExpression(): void + { + $this->registerPlainSymfonyExpressionResolver(); + $this->cache->method('isValidTag')->willReturn(true); + $service = new ResponseCacheService($this->cache, new Context()); + + self::assertSame( + ['author_7'], + $service->evaluateStoreTagExpressions( + ["'author_' ~ object.getAuthorUid()"], + $this->createOperation([]), + [], + Request::create('https://example.com/_api/books', 'GET'), + ['object' => new class () { + public function getAuthorUid(): int + { + return 7; + } + }] + ) + ); + } + + #[Test] + public function evaluateStoreTagExpressionsFailsClosedAndLogsErrorOnEvaluationFailure(): void + { + $this->registerPlainSymfonyExpressionResolver(); + $logger = $this->createMock(LoggerInterface::class); + $logger->expects(self::once())->method('error'); + + $service = new ResponseCacheService($this->cache, new Context()); + $service->setLogger($logger); + + self::assertNull( + $service->evaluateStoreTagExpressions( + ['thisFunctionDoesNotExist()'], + $this->createOperation([]), + [], + Request::create('https://example.com/_api/books', 'GET') + ) + ); + } + + #[Test] + public function evaluateStoreTagExpressionsFailsClosedAndLogsErrorOnInvalidTag(): void + { + $this->registerPlainSymfonyExpressionResolver(); + $this->cache->method('isValidTag')->willReturn(false); + $logger = $this->createMock(LoggerInterface::class); + $logger->expects(self::once())->method('error'); + + $service = new ResponseCacheService($this->cache, new Context()); + $service->setLogger($logger); + + self::assertNull( + $service->evaluateStoreTagExpressions( + ["'invalid tag with spaces'"], + $this->createOperation([]), + [], + Request::create('https://example.com/_api/books', 'GET') + ) + ); + } + + #[Test] + public function evaluateFlushTagExpressionsSkipsEmptyResultAndKeepsResolvedTag(): void + { + $this->cache->method('isValidTag')->willReturn(true); + $operation = $this->createOperation([]); + $request = Request::create('https://example.com/_api/books', 'GET'); + $tagExpressions = [ + "context.getPropertyFromAspect('frontend.user', 'isLoggedIn', '') " + . "? 'fe_user_' ~ context.getPropertyFromAspect('frontend.user', 'id', '') : ''", + ]; + + $loggedOutContext = new Context(); + $this->registerPlainSymfonyExpressionResolver(['context' => $loggedOutContext]); + + self::assertSame( + [], + (new ResponseCacheService($this->cache, $loggedOutContext)) + ->evaluateFlushTagExpressions($tagExpressions, $operation, [], $request) + ); + + $loggedInUser = $this->createMock(AbstractUserAuthentication::class); + $loggedInUser->userid_column = 'uid'; + $loggedInUser->user = ['uid' => 1]; + + $loggedInContext = new Context(); + $loggedInContext->setAspect('frontend.user', new UserAspect($loggedInUser)); + $this->registerPlainSymfonyExpressionResolver(['context' => $loggedInContext]); + + self::assertSame( + ['fe_user_1'], + (new ResponseCacheService($this->cache, $loggedInContext)) + ->evaluateFlushTagExpressions($tagExpressions, $operation, [], $request) + ); + } + + #[Test] + public function evaluateFlushTagExpressionsCanUseMatchedRouteParameter(): void + { + $this->registerPlainSymfonyExpressionResolver(); + $this->cache->method('isValidTag')->willReturn(true); + $service = new ResponseCacheService($this->cache, new Context()); + + self::assertSame( + ['tx_myext_domain_model_recipe_42'], + $service->evaluateFlushTagExpressions( + ["'tx_myext_domain_model_recipe_' ~ route['recipeId']"], + $this->createOperation([]), + ['recipeId' => '42', '_route' => 'recipes_favorite'], + Request::create('https://example.com/_api/recipes/42/favorite', 'POST') + ) + ); + } + + /** + * `object` is not part of the standard tag-expression variable set - `OperationResponseCache` + * adds it explicitly, via `$additionalVariables`, for `cacheInvalidation.tagExpressions` (the + * written entity, or `null` on a `DELETE`, see `flushInvalidationTagsAfterSuccessfulWrite()`). + * This exercises the plumbing directly: a `null` `object` is a valid value, not an evaluation + * failure, and the null-safe idiom documented for `cacheInvalidation` resolves it to no tag. + */ + #[Test] + public function evaluateFlushTagExpressionsResolvesNullObjectViaNullSafeIdiom(): void + { + $this->registerPlainSymfonyExpressionResolver(); + $this->cache->method('isValidTag')->willReturn(true); + $service = new ResponseCacheService($this->cache, new Context()); + + self::assertSame( + [], + $service->evaluateFlushTagExpressions( + ["object != null ? 'tx_myext_domain_model_recipe_' ~ object.getUid() : ''"], + $this->createOperation([]), + [], + Request::create('https://example.com/_api/recipes/42', 'DELETE'), + ['object' => null] + ) + ); + } + + /** + * Fail-soft, unlike the store side above: a failing expression is skipped (with a warning), + * not fatal to the others - the second, valid expression still contributes its tag. + */ + #[Test] + public function evaluateFlushTagExpressionsSkipsFailingExpressionAndLogsWarningButKeepsOthers(): void + { + $this->registerPlainSymfonyExpressionResolver(); + $this->cache->method('isValidTag')->willReturn(true); + $logger = $this->createMock(LoggerInterface::class); + $logger->expects(self::once())->method('warning'); + + $service = new ResponseCacheService($this->cache, new Context()); + $service->setLogger($logger); + + self::assertSame( + ['fe_user_1'], + $service->evaluateFlushTagExpressions( + ['thisFunctionDoesNotExist()', "'fe_user_1'"], + $this->createOperation([]), + [], + Request::create('https://example.com/_api/books', 'GET') + ) + ); + } + + #[Test] + public function evaluateFlushTagExpressionsSkipsInvalidTagAndLogsWarning(): void + { + $this->registerPlainSymfonyExpressionResolver(); + $this->cache->method('isValidTag')->willReturn(false); + $logger = $this->createMock(LoggerInterface::class); + $logger->expects(self::once())->method('warning'); + + $service = new ResponseCacheService($this->cache, new Context()); + $service->setLogger($logger); + + self::assertSame( + [], + $service->evaluateFlushTagExpressions( + ["'invalid tag with spaces'"], + $this->createOperation([]), + [], + Request::create('https://example.com/_api/books', 'GET') + ) + ); + } + + /** + * Registers a `Resolver` double backed by a plain Symfony `ExpressionLanguage` (without + * TYPO3's provider loading, which needs a booted TYPO3 instance) so the conditions are + * evaluated by the real expression engine against the real variable set built by the + * service, plus `$providerVariables` standing in for whatever an expression-language + * provider (e.g. `T3apiCoreProvider`'s `context`) would otherwise contribute - merged the + * same way the real `Resolver` merges provider variables under per-call ones. + * + * @param array $providerVariables + */ + private function registerPlainSymfonyExpressionResolver(array $providerVariables = []): void + { + GeneralUtility::addInstance(Resolver::class, new class ($providerVariables) extends Resolver { + private readonly ExpressionLanguage $symfonyExpressionLanguage; + + public function __construct(private readonly array $providerVariables) + { + $this->symfonyExpressionLanguage = new ExpressionLanguage(); + } + + public function evaluate(string $expression, array $contextVariables = []): mixed + { + return $this->symfonyExpressionLanguage->evaluate( + $expression, + array_replace($this->providerVariables, $contextVariables) + ); + } + }); + } + + private function createSecuredTitleFilter(): ApiFilter + { + return new ApiFilter( + \SourceBroker\T3api\Filter\SearchFilter::class, + 'title', + ['name' => 'partial', 'condition' => 'is_granted("ROLE_ADMIN")'], + [] + ); + } + + /** + * `$cacheAttributes` mirrors an operation's `attributes.cache` block. `null` means caching is + * disabled entirely (no block declared); a given array is always enabled - same as a non-empty + * `cache` block without an explicit `enabled` key. + * + * @param ApiFilter[]|null $filters + */ + private function createOperation(?array $cacheAttributes, ?array $filters = null): CollectionOperation + { + $responseCacheSettings = $cacheAttributes === null + ? ResponseCacheSettings::create() + : ResponseCacheSettings::create(['enabled' => true] + $cacheAttributes); + + $pagination = Pagination::create(PaginationTest::DEFAULT_API_RESOURCE_PAGINATION_ATTRIBUTES); + + $operation = $this->createMock(CollectionOperation::class); + $operation->method('getResponseCacheSettings')->willReturn($responseCacheSettings); + $operation->method('getPagination')->willReturn($pagination); + $operation->method('getFilters')->willReturn($filters ?? [ + new ApiFilter(\SourceBroker\T3api\Filter\SearchFilter::class, 'title', 'partial', []), + ]); + + return $operation; + } +} diff --git a/composer.json b/composer.json index fa283ee..27ab17d 100644 --- a/composer.json +++ b/composer.json @@ -27,9 +27,9 @@ "symfony/property-info": "^6.4 || ^7.0", "symfony/psr-http-message-bridge": "^6.4 || ^7.0", "symfony/routing": "^6.4 || ^7.0", - "typo3/cms-core": "^12.4.16 || ^13.3 || ^14.0", - "typo3/cms-extbase": "^12.4.16 || ^13.3 || ^14.0", - "typo3/cms-frontend": "^12.4.16 || ^13.3 || ^14.0" + "typo3/cms-core": "^12.4.18 || ^13.3 || ^14.0", + "typo3/cms-extbase": "^12.4.18 || ^13.3 || ^14.0", + "typo3/cms-frontend": "^12.4.18 || ^13.3 || ^14.0" }, "require-dev": { "composer/class-map-generator": "^1.7", @@ -40,7 +40,7 @@ "saschaegerer/phpstan-typo3": "~1.10.2 || ^3.0", "seld/jsonlint": "^1.11.0", "symfony/yaml": "^6.0 || ^7.0", - "typo3/cms-install": "^12.4.16 || ^13.3 || ^14.0", + "typo3/cms-install": "^12.4.18 || ^13.3 || ^14.0", "typo3/coding-standards": "^0.8", "typo3/testing-framework": "^8.2.1 || ^9.5" }, @@ -54,7 +54,8 @@ }, "autoload-dev": { "psr-4": { - "SourceBroker\\T3api\\Tests\\": "Tests" + "SourceBroker\\T3api\\Tests\\": "Tests", + "T3apiTests\\ResponseCacheTest\\": "Tests/Functional/Fixtures/Extensions/t3api_response_cache_test/Classes" } }, "config": { @@ -88,7 +89,7 @@ "ci:composer:normalize": "@composer normalize --dry-run", "ci:json:lint": "find ./composer.json ./Resources ./Configuration -name '*.json' | xargs -r php .Build/bin/jsonlint -q", "ci:php:cs-fixer": "PHP_CS_FIXER_IGNORE_ENV=1 .Build/bin/php-cs-fixer fix --config .php-cs-fixer.php -v --dry-run --using-cache no --diff", - "ci:php:lint": "find . -name '*.php' -not -path './.Build/*' -not -path './.cache/*' -not -path './.ddev/*' -not -path './.test/*' -not -path './.Documentation/*' -not -path './.Documentation-GENERATED-temp/*' -print0 | xargs -0 -n 1 -P 4 php -l > /dev/null", + "ci:php:lint": "find . -name '*.php' -not -path './.Build/*' -not -path './.cache/*' -not -path './.ddev/*' -not -path './.test/*' -not -path './.Documentation/*' -not -path './.Documentation-GENERATED-temp/*' -not -path './.worktrees/*' -print0 | xargs -0 -n 1 -P 4 php -l > /dev/null", "ci:php:stan": ".Build/bin/phpstan --no-progress", "ci:tests:create-directories": "mkdir -p .Build/public/typo3temp/var/tests", "ci:tests:functional": [ diff --git a/ext_localconf.php b/ext_localconf.php index 694f010..6223b72 100644 --- a/ext_localconf.php +++ b/ext_localconf.php @@ -55,6 +55,7 @@ static function () { \SourceBroker\T3api\Serializer\Subscriber\AbstractEntitySubscriber::class, \SourceBroker\T3api\Serializer\Subscriber\ThrowableSubscriber::class, \SourceBroker\T3api\Serializer\Subscriber\CurrentFeUserSubscriber::class, + \SourceBroker\T3api\Serializer\Subscriber\CacheTagSubscriber::class, ]; $GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['t3api']['serializerMetadataDirs'] = [ @@ -107,6 +108,8 @@ static function () { ]; } + $GLOBALS['TYPO3_CONF_VARS']['SYS']['caching']['cacheConfigurations']['t3api_response'] ??= []; + $GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['t3api']['serializer']['exclusionForExceptionsInAccessorStrategyGetValue'] = [ TYPO3\CMS\Core\Resource\FileReference::class => [ \TYPO3\CMS\Core\Resource\Exception\FileDoesNotExistException::class, @@ -121,6 +124,13 @@ static function () { $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['clearCachePostProc']['t3api_clearcache'] = \SourceBroker\T3api\Service\SerializerService::class . '->clearCache'; + $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['clearCachePostProc']['t3api_response_cache'] = + \SourceBroker\T3api\Service\ResponseCacheService::class . '->clearCachePostProc'; + $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['processDatamapClass']['t3api'] = + \SourceBroker\T3api\Hook\ResponseCacheDataHandlerHook::class; + $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['processCmdmapClass']['t3api'] = + \SourceBroker\T3api\Hook\ResponseCacheDataHandlerHook::class; + if ((new Typo3Version())->getMajorVersion() < 13) { $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tslib/class.tslib_fe.php']['createHashBase']['t3api'] = \SourceBroker\T3api\Hook\EnrichHashBase::class.'->init';