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';