Skip to content

Commit c35a0ab

Browse files
authored
[FEATURE] Tag-based response cache for GET endpoints
Resolves #146, #147
1 parent 46cba3f commit c35a0ab

63 files changed

Lines changed: 7691 additions & 59 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace SourceBroker\T3api\Command;
6+
7+
use Symfony\Component\Console\Attribute\AsCommand;
8+
use Symfony\Component\Console\Command\Command;
9+
use Symfony\Component\Console\Input\InputInterface;
10+
use Symfony\Component\Console\Output\OutputInterface;
11+
use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface;
12+
use TYPO3\CMS\Core\Database\Connection;
13+
use TYPO3\CMS\Core\Database\ConnectionPool;
14+
use TYPO3\CMS\Core\Registry;
15+
16+
#[AsCommand(
17+
name: 't3api:cache:invalidate-expired',
18+
description: 'Invalidates the t3api response cache for tables where a start or end time passed since the last run.'
19+
)]
20+
class InvalidateExpiredResponseCacheCommand extends Command
21+
{
22+
protected const REGISTRY_NAMESPACE = 't3api_response';
23+
24+
public function __construct(
25+
protected readonly ConnectionPool $connectionPool,
26+
protected readonly Registry $registry,
27+
protected readonly FrontendInterface $cache
28+
) {
29+
parent::__construct();
30+
}
31+
32+
protected function execute(InputInterface $input, OutputInterface $output): int
33+
{
34+
$executionTimestamp = time();
35+
foreach ($this->getTimeRestrictedTables() as $table) {
36+
$registryKey = $this->getRegistryKeyForTable($table);
37+
$lastExecutionTimestamp = (int)$this->registry->get(self::REGISTRY_NAMESPACE, $registryKey);
38+
39+
if ($this->hasTimeBasedVisibilityChanged($table, $lastExecutionTimestamp, $executionTimestamp)) {
40+
$output->writeln(sprintf('Flushing t3api response cache for table `%s`', $table));
41+
$this->cache->flushByTags([$table]);
42+
}
43+
44+
// Persisted only after the visibility check (and the flush it may have triggered)
45+
// completed without throwing, so a DB error or crash does not silently lose this
46+
// invalidation window - the next run will simply re-check it.
47+
$this->registry->set(self::REGISTRY_NAMESPACE, $registryKey, $executionTimestamp);
48+
}
49+
50+
return Command::SUCCESS;
51+
}
52+
53+
/**
54+
* Scans every TCA table with a starttime/endtime enablecolumn, not just cacheable
55+
* resources: a cached response can embed related entities from other tables (e.g. an
56+
* author nested in a book response), so a table never checked here could never trigger
57+
* the flush that keeps those responses fresh. This broad scan stays safe because the
58+
* flush itself is tag-scoped - `flushByTags([$table])` only ever hits entries that
59+
* actually embedded a record of that table.
60+
*
61+
* @return string[]
62+
*/
63+
protected function getTimeRestrictedTables(): array
64+
{
65+
$tables = [];
66+
foreach (array_keys($GLOBALS['TCA']) as $table) {
67+
if ($this->getStartTimeAndEndTimeFields($table) !== []) {
68+
$tables[] = $table;
69+
}
70+
}
71+
72+
return $tables;
73+
}
74+
75+
protected function hasTimeBasedVisibilityChanged(string $table, int $lastExecutionTimestamp, int $executionTimestamp): bool
76+
{
77+
$enableFields = $this->getStartTimeAndEndTimeFields($table);
78+
if ($enableFields === []) {
79+
return false;
80+
}
81+
82+
$queryBuilder = $this->connectionPool->getQueryBuilderForTable($table);
83+
$queryBuilder->getRestrictions()->removeAll();
84+
85+
$constraints = [];
86+
foreach ($enableFields as $enableField) {
87+
$constraints[] = $queryBuilder->expr()->and(
88+
$queryBuilder->expr()->gt(
89+
$enableField,
90+
$queryBuilder->createNamedParameter($lastExecutionTimestamp, Connection::PARAM_INT)
91+
),
92+
$queryBuilder->expr()->lte(
93+
$enableField,
94+
$queryBuilder->createNamedParameter($executionTimestamp, Connection::PARAM_INT)
95+
)
96+
);
97+
}
98+
99+
return (int)$queryBuilder
100+
->count('uid')
101+
->from($table)
102+
->where($queryBuilder->expr()->or(...$constraints))
103+
->executeQuery()
104+
->fetchOne() > 0;
105+
}
106+
107+
/**
108+
* @return string[]
109+
*/
110+
protected function getStartTimeAndEndTimeFields(string $table): array
111+
{
112+
return array_filter([
113+
'starttime' => $GLOBALS['TCA'][$table]['ctrl']['enablecolumns']['starttime'] ?? null,
114+
'endtime' => $GLOBALS['TCA'][$table]['ctrl']['enablecolumns']['endtime'] ?? null,
115+
]);
116+
}
117+
118+
protected function getRegistryKeyForTable(string $table): string
119+
{
120+
return sprintf('%s_lastExecution', $table);
121+
}
122+
}

Classes/Dispatcher/AbstractDispatcher.php

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
use SourceBroker\T3api\Processor\ProcessorInterface;
1616
use SourceBroker\T3api\Serializer\ContextBuilder\DeserializationContextBuilder;
1717
use SourceBroker\T3api\Serializer\ContextBuilder\SerializationContextBuilder;
18+
use SourceBroker\T3api\Service\OperationResponseCache;
1819
use SourceBroker\T3api\Service\SerializerService;
1920
use Symfony\Component\HttpFoundation\Request;
2021
use Symfony\Component\Routing\Exception\MethodNotAllowedException as SymfonyMethodNotAllowedException;
@@ -35,18 +36,22 @@ abstract class AbstractDispatcher
3536

3637
protected DeserializationContextBuilder $deserializationContextBuilder;
3738

39+
protected OperationResponseCache $operationResponseCache;
40+
3841
public function __construct(
3942
SerializerService $serializerService,
4043
ApiResourceRepository $apiResourceRepository,
4144
SerializationContextBuilder $serializationContextBuilder,
4245
DeserializationContextBuilder $deserializationContextBuilder,
43-
EventDispatcherInterface $eventDispatcherInterface
46+
EventDispatcherInterface $eventDispatcherInterface,
47+
OperationResponseCache $operationResponseCache
4448
) {
4549
$this->serializerService = $serializerService;
4650
$this->apiResourceRepository = $apiResourceRepository;
4751
$this->serializationContextBuilder = $serializationContextBuilder;
4852
$this->deserializationContextBuilder = $deserializationContextBuilder;
4953
$this->eventDispatcher = $eventDispatcherInterface;
54+
$this->operationResponseCache = $operationResponseCache;
5055
}
5156

5257
/**
@@ -62,12 +67,18 @@ public function processOperationByRequest(
6267
try {
6368
$matchedRoute = (new UrlMatcher($apiResource->getRoutes(), $requestContext))
6469
->matchRequest($request);
70+
$operation = $apiResource->getOperationByRouteName($matchedRoute['_route']);
71+
$result = null;
6572

66-
return $this->processOperation(
67-
$apiResource->getOperationByRouteName($matchedRoute['_route']),
73+
return $this->operationResponseCache->resolve(
74+
$operation,
6875
$matchedRoute,
6976
$request,
70-
$response
77+
function () use ($operation, $matchedRoute, $request, &$response, &$result) {
78+
return $this->processOperation($operation, $matchedRoute, $request, $response, $result);
79+
},
80+
$response,
81+
$result
7182
);
7283
} catch (SymfonyResourceNotFoundException $resourceNotFoundException) {
7384
// do not stop - continue to find correct route
@@ -80,17 +91,27 @@ public function processOperationByRequest(
8091
}
8192

8293
/**
94+
* `$result` is an internal, protected-only out-parameter: it receives the operation handler's
95+
* un-serialized result (post `AfterProcessOperationEvent`) so `processOperationByRequest()`'s
96+
* closure can hand it to `OperationResponseCache::resolve()`, which needs it to evaluate the
97+
* `object` expression variable in `cache.memberTagExpressions` and
98+
* `cacheInvalidation.tagExpressions` - see `OperationResponseCache::resolve()`. It is not part
99+
* of this method's public contract; callers that do not need it simply omit it, same as
100+
* `$response`.
101+
*
83102
* @param OperationInterface $operation
84103
* @param array $route
85104
* @param Request $request
86105
* @param ResponseInterface|null $response
106+
* @param mixed $result
87107
* @return string
88108
*/
89109
protected function processOperation(
90110
OperationInterface $operation,
91111
array $route,
92112
Request $request,
93-
?ResponseInterface &$response = null
113+
?ResponseInterface &$response = null,
114+
mixed &$result = null
94115
): string {
95116
$handlers = $this->getHandlersSupportingOperation($operation, $request);
96117

Classes/Dispatcher/Bootstrap.php

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
use SourceBroker\T3api\Exception\ExceptionInterface;
1212
use SourceBroker\T3api\Serializer\ContextBuilder\DeserializationContextBuilder;
1313
use SourceBroker\T3api\Serializer\ContextBuilder\SerializationContextBuilder;
14+
use SourceBroker\T3api\Service\OperationResponseCache;
1415
use SourceBroker\T3api\Service\RouteService;
1516
use SourceBroker\T3api\Service\SerializerService;
1617
use Symfony\Bridge\PsrHttpMessage\Factory\HttpFoundationFactory;
@@ -34,14 +35,16 @@ public function __construct(
3435
ApiResourceRepository $apiResourceRepository,
3536
SerializationContextBuilder $serializationContextBuilder,
3637
DeserializationContextBuilder $deserializationContextBuilder,
37-
EventDispatcherInterface $eventDispatcherInterface
38+
EventDispatcherInterface $eventDispatcherInterface,
39+
OperationResponseCache $operationResponseCache
3840
) {
3941
parent::__construct(
4042
$serializerService,
4143
$apiResourceRepository,
4244
$serializationContextBuilder,
4345
$deserializationContextBuilder,
4446
$eventDispatcherInterface,
47+
$operationResponseCache,
4548
);
4649
$this->response = new Response('php://temp', 200, ['Content-Type' => 'application/ld+json']);
4750
$this->httpFoundationFactory = GeneralUtility::makeInstance(HttpFoundationFactory::class);

Classes/Domain/Model/AbstractOperation.php

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ abstract class AbstractOperation implements OperationInterface
3232

3333
protected UploadSettings $uploadSettings;
3434

35+
protected ResponseCacheSettings $responseCacheSettings;
36+
37+
protected CacheInvalidationSettings $cacheInvalidationSettings;
38+
3539
public function __construct(string $key, ApiResource $apiResource, array $params)
3640
{
3741
$this->key = $key;
@@ -63,6 +67,14 @@ public function __construct(string $key, ApiResource $apiResource, array $params
6367
$params['attributes']['upload'] ?? [],
6468
$apiResource->getUploadSettings()
6569
);
70+
$this->responseCacheSettings = ResponseCacheSettings::create(
71+
$params['attributes']['cache'] ?? [],
72+
$apiResource->getResponseCacheSettings()
73+
);
74+
$this->cacheInvalidationSettings = CacheInvalidationSettings::create(
75+
$params['attributes']['cacheInvalidation'] ?? [],
76+
$apiResource->getCacheInvalidationSettings()
77+
);
6678
}
6779

6880
public function getKey(): string
@@ -144,4 +156,14 @@ public function getUploadSettings(): UploadSettings
144156
{
145157
return $this->uploadSettings;
146158
}
159+
160+
public function getResponseCacheSettings(): ResponseCacheSettings
161+
{
162+
return $this->responseCacheSettings;
163+
}
164+
165+
public function getCacheInvalidationSettings(): CacheInvalidationSettings
166+
{
167+
return $this->cacheInvalidationSettings;
168+
}
147169
}

Classes/Domain/Model/ApiResource.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ class ApiResource
3434

3535
protected UploadSettings $uploadSettings;
3636

37+
protected ResponseCacheSettings $responseCacheSettings;
38+
39+
protected CacheInvalidationSettings $cacheInvalidationSettings;
40+
3741
public function __construct(string $entity, ApiResourceAnnotation $apiResourceAnnotation)
3842
{
3943
$this->entity = $entity;
@@ -43,6 +47,8 @@ public function __construct(string $entity, ApiResourceAnnotation $apiResourceAn
4347
$this->pagination = Pagination::create($attributes);
4448
$this->persistenceSettings = PersistenceSettings::create($attributes['persistence'] ?? []);
4549
$this->uploadSettings = UploadSettings::create($attributes['upload'] ?? []);
50+
$this->responseCacheSettings = ResponseCacheSettings::create($attributes['cache'] ?? []);
51+
$this->cacheInvalidationSettings = CacheInvalidationSettings::create($attributes['cacheInvalidation'] ?? []);
4652

4753
foreach ($apiResourceAnnotation->getItemOperations() as $operationKey => $operationData) {
4854
$this->itemOperations[] = new ItemOperation($operationKey, $this, $operationData);
@@ -151,4 +157,14 @@ public function getUploadSettings(): UploadSettings
151157
{
152158
return $this->uploadSettings;
153159
}
160+
161+
public function getResponseCacheSettings(): ResponseCacheSettings
162+
{
163+
return $this->responseCacheSettings;
164+
}
165+
166+
public function getCacheInvalidationSettings(): CacheInvalidationSettings
167+
{
168+
return $this->cacheInvalidationSettings;
169+
}
154170
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace SourceBroker\T3api\Domain\Model;
6+
7+
class CacheInvalidationSettings extends AbstractOperationResourceSettings
8+
{
9+
protected bool $explicitlyConfigured = false;
10+
11+
/**
12+
* @var string[]
13+
*/
14+
protected array $tags = [];
15+
16+
/**
17+
* @var string[]
18+
*/
19+
protected array $tagExpressions = [];
20+
21+
/**
22+
* @param array $attributes
23+
* @param CacheInvalidationSettings|null $base
24+
* @return CacheInvalidationSettings
25+
*/
26+
public static function create(
27+
array $attributes = [],
28+
?AbstractOperationResourceSettings $base = null
29+
): AbstractOperationResourceSettings {
30+
$cacheInvalidationSettings = parent::create($attributes, $base);
31+
$cacheInvalidationSettings->explicitlyConfigured = $attributes !== [];
32+
$cacheInvalidationSettings->tags = $attributes['tags'] ?? $cacheInvalidationSettings->tags;
33+
$cacheInvalidationSettings->tagExpressions = $attributes['tagExpressions']
34+
?? $cacheInvalidationSettings->tagExpressions;
35+
36+
return $cacheInvalidationSettings;
37+
}
38+
39+
/**
40+
* True when THIS settings object's own `attributes` block was non-empty - a resource-level
41+
* block, or a per-operation block declared directly on the operation. False when the
42+
* settings were produced purely by cascading a base's values forward (an empty per-operation
43+
* `attributes` block inheriting a resource-level block, or the empty default). Used to tell
44+
* apart "this operation itself configured `cacheInvalidation`" from "this operation merely
45+
* inherited it" - see {@see \SourceBroker\T3api\Service\ApiResourceConfigurationValidator}.
46+
*/
47+
public function wasExplicitlyConfigured(): bool
48+
{
49+
return $this->explicitlyConfigured;
50+
}
51+
52+
/**
53+
* Literal tags flushed after a non-GET operation executes successfully, regardless of whether
54+
* the operation itself has a cacheable response. Plain strings only - for a tag whose value
55+
* depends on the matched route parameters (or anything else dynamic), use `tagExpressions`
56+
* instead.
57+
*
58+
* @return string[]
59+
*/
60+
public function getTags(): array
61+
{
62+
return $this->tags;
63+
}
64+
65+
/**
66+
* Symfony expressions whose non-empty string results are each flushed as an extra tag
67+
* alongside `tags`, after a non-GET operation executes successfully - lets a write flush
68+
* anything visible to the expression, e.g. the matched route parameters (via `route`, see
69+
* :ref:`response-cache-conditions`) or exactly the current user's entries (via a
70+
* project-provided `user` variable). Evaluated with the same resolver and variable set as
71+
* `readCondition`/`identifierExpressions`. An empty string result is a valid "no tag from this
72+
* expression" result, the idiom for conditional tagging.
73+
*
74+
* @return string[]
75+
*/
76+
public function getTagExpressions(): array
77+
{
78+
return $this->tagExpressions;
79+
}
80+
}

Classes/Domain/Model/OperationInterface.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ public function getSecurityPostDenormalize(): string;
2828

2929
public function getPersistenceSettings(): PersistenceSettings;
3030

31+
public function getResponseCacheSettings(): ResponseCacheSettings;
32+
33+
public function getCacheInvalidationSettings(): CacheInvalidationSettings;
34+
3135
public function isMethodGet(): bool;
3236

3337
public function isMethodPut(): bool;

0 commit comments

Comments
 (0)