Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions Classes/Command/InvalidateExpiredResponseCacheCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
<?php

declare(strict_types=1);

namespace SourceBroker\T3api\Command;

use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Registry;

#[AsCommand(
name: 't3api:cache:invalidate-expired',
description: 'Invalidates the t3api response cache for tables where a start or end time passed since the last run.'
)]
class InvalidateExpiredResponseCacheCommand extends Command
{
protected const REGISTRY_NAMESPACE = 't3api_response';

public function __construct(
protected readonly ConnectionPool $connectionPool,
protected readonly Registry $registry,
protected readonly FrontendInterface $cache
) {
parent::__construct();
}

protected function execute(InputInterface $input, OutputInterface $output): int
{
$executionTimestamp = time();
foreach ($this->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);
}
}
31 changes: 26 additions & 5 deletions Classes/Dispatcher/AbstractDispatcher.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}

/**
Expand All @@ -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
Expand All @@ -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);

Expand Down
5 changes: 4 additions & 1 deletion Classes/Dispatcher/Bootstrap.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -34,14 +35,16 @@ public function __construct(
ApiResourceRepository $apiResourceRepository,
SerializationContextBuilder $serializationContextBuilder,
DeserializationContextBuilder $deserializationContextBuilder,
EventDispatcherInterface $eventDispatcherInterface
EventDispatcherInterface $eventDispatcherInterface,
OperationResponseCache $operationResponseCache
) {
parent::__construct(
$serializerService,
$apiResourceRepository,
$serializationContextBuilder,
$deserializationContextBuilder,
$eventDispatcherInterface,
$operationResponseCache,
);
$this->response = new Response('php://temp', 200, ['Content-Type' => 'application/ld+json']);
$this->httpFoundationFactory = GeneralUtility::makeInstance(HttpFoundationFactory::class);
Expand Down
22 changes: 22 additions & 0 deletions Classes/Domain/Model/AbstractOperation.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
}
16 changes: 16 additions & 0 deletions Classes/Domain/Model/ApiResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand Down Expand Up @@ -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;
}
}
80 changes: 80 additions & 0 deletions Classes/Domain/Model/CacheInvalidationSettings.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
<?php

declare(strict_types=1);

namespace SourceBroker\T3api\Domain\Model;

class CacheInvalidationSettings extends AbstractOperationResourceSettings
{
protected bool $explicitlyConfigured = false;

/**
* @var string[]
*/
protected array $tags = [];

/**
* @var string[]
*/
protected array $tagExpressions = [];

/**
* @param array $attributes
* @param CacheInvalidationSettings|null $base
* @return CacheInvalidationSettings
*/
public static function create(
array $attributes = [],
?AbstractOperationResourceSettings $base = null
): AbstractOperationResourceSettings {
$cacheInvalidationSettings = parent::create($attributes, $base);
$cacheInvalidationSettings->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;
}
}
4 changes: 4 additions & 0 deletions Classes/Domain/Model/OperationInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading