Skip to content

Commit cffe992

Browse files
committed
Change caching to use Symfony
1 parent d5e2502 commit cffe992

14 files changed

Lines changed: 225 additions & 253 deletions

config/packages/security.php

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -241,12 +241,12 @@
241241
],
242242
[
243243
'path' => '^/api/search/?$',
244-
'roles' => 'PUBLIC_ACCESS',
245-
'methods' => ['GET'],
246-
'requires_channel' => '%env(SECURE_SCHEME)%',
244+
'roles' => 'PUBLIC_ACCESS',
245+
'methods' => ['GET'],
246+
'requires_channel' => '%env(SECURE_SCHEME)%',
247247
],
248-
[
249-
'path' => '^/api/project/[a-zA-Z0-9\\\-]+/catrobat/?$',
248+
[
249+
'path' => '^/api/project/[a-zA-Z0-9\\\-]+/catrobat/?$',
250250
'roles' => 'PUBLIC_ACCESS',
251251
'methods' => ['GET'],
252252
'requires_channel' => '%env(SECURE_SCHEME)%',
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace DoctrineMigrations;
6+
7+
use Doctrine\DBAL\Schema\Schema;
8+
use Doctrine\Migrations\AbstractMigration;
9+
10+
/**
11+
* Auto-generated Migration: Please modify to your needs!
12+
*/
13+
final class Version20260115142912 extends AbstractMigration
14+
{
15+
public function getDescription(): string
16+
{
17+
return '';
18+
}
19+
20+
public function up(Schema $schema): void
21+
{
22+
// this up() migration is auto-generated, please modify it to your needs
23+
$this->addSql('DROP TABLE response_cache');
24+
}
25+
26+
public function down(Schema $schema): void
27+
{
28+
// this down() migration is auto-generated, please modify it to your needs
29+
$this->addSql('CREATE TABLE response_cache (id VARCHAR(255) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_ci`, response_code INT NOT NULL, response LONGTEXT CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_ci`, response_headers VARCHAR(255) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_ci`, cached_at DATETIME NOT NULL, PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB COMMENT = \'\' ');
30+
}
31+
}

src/Api/ProjectsApi.php

Lines changed: 50 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66

77
use App\Api\Services\Base\AbstractApiController;
88
use App\Api\Services\Projects\ProjectsApiFacade;
9-
use App\DB\Entity\Api\ResponseCache;
109
use App\DB\Entity\Project\Program;
1110
use App\DB\Entity\Project\ProgramDownloads;
1211
use App\Project\AddProjectRequest;
@@ -112,22 +111,33 @@ public function projectsFeaturedGet(string $platform, string $max_version, int $
112111

113112
/**
114113
* @throws \JsonException
115-
* @throws \DateMalformedStringException
114+
* @throws \Psr\Cache\InvalidArgumentException
116115
*/
117116
#[\Override]
118117
public function projectsGet(string $category, string $accept_language, string $max_version, int $limit, int $offset, string $attributes, string $flavor, int &$responseCode, array &$responseHeaders): array
119118
{
120119
$locale = $this->facade->getResponseManager()->sanitizeLocale($accept_language);
121-
122120
$cache_id = sprintf('projectsGet_%s_%s_%s_%s_%d_%d', $category, $locale, $flavor, $max_version, $limit, $offset);
123-
if ('recent' !== $category) {
124-
$cached_response = $this->facade->getResponseManager()->getCachedResponse($cache_id);
125-
if ($cached_response instanceof ResponseCache) {
126-
$responseCode = $cached_response->getResponseCode();
127-
$responseHeaders = $this->facade->getResponseManager()->extractResponseHeader($cached_response);
128-
129-
return $this->facade->getResponseManager()->extractResponseObject($cached_response);
130-
}
121+
122+
// Don't cache 'recent' category as it changes frequently
123+
if ('recent' === $category) {
124+
$user = $this->facade->getAuthenticationManager()->getAuthenticatedUser();
125+
$projects = $this->facade->getLoader()->getProjectsFromCategory($category, $max_version, $limit, $offset, $flavor, $user);
126+
127+
$responseCode = Response::HTTP_OK;
128+
$response = $this->facade->getResponseManager()->createProjectsDataResponse($projects, $attributes);
129+
$this->facade->getResponseManager()->addResponseHashToHeaders($responseHeaders, $response);
130+
$this->facade->getResponseManager()->addContentLanguageToHeaders($responseHeaders);
131+
132+
return $response;
133+
}
134+
135+
$cached = $this->facade->getResponseManager()->getCachedResponse($cache_id);
136+
if (null !== $cached) {
137+
$responseCode = $cached['response_code'];
138+
$responseHeaders = $cached['response_headers'];
139+
140+
return $cached['response'];
131141
}
132142

133143
$user = $this->facade->getAuthenticationManager()->getAuthenticatedUser();
@@ -231,40 +241,46 @@ public function projectsSearchGet(string $query, string $max_version, int $limit
231241

232242
/**
233243
* @throws \JsonException
234-
* @throws \DateMalformedStringException
244+
* @throws \Psr\Cache\InvalidArgumentException
235245
*/
236246
#[\Override]
237247
public function projectsCategoriesGet(string $max_version, string $flavor, string $accept_language, int &$responseCode, array &$responseHeaders): array
238248
{
239249
$limit = 20;
240250
$offset = 0;
241251
$locale = $this->facade->getResponseManager()->sanitizeLocale($accept_language);
242-
243252
$cache_id = sprintf('projectsCategoriesGet_%s_%s_%s', $flavor, $locale, $max_version);
244-
$cached_response = $this->facade->getResponseManager()->getCachedResponse($cache_id);
245-
if ($cached_response instanceof ResponseCache) {
246-
$responseCode = $cached_response->getResponseCode();
247-
$responseHeaders = $this->facade->getResponseManager()->extractResponseHeader($cached_response);
248-
249-
return $this->facade->getResponseManager()->extractResponseObject($cached_response);
250-
}
251253

252-
$response = [];
253-
254-
$categories = ['recent', 'example', 'most_downloaded', 'random', 'scratch', 'trending'];
255-
$user = $this->facade->getAuthenticationManager()->getAuthenticatedUser();
254+
// Use getCachedOrCompute for cleaner code
255+
$cached = $this->facade->getResponseManager()->getCachedOrCompute(
256+
$cache_id,
257+
function () use ($max_version, $limit, $offset, $flavor, $accept_language) {
258+
$response = [];
259+
$categories = ['recent', 'example', 'most_downloaded', 'random', 'scratch', 'trending'];
260+
$user = $this->facade->getAuthenticationManager()->getAuthenticatedUser();
261+
262+
foreach ($categories as $category) {
263+
$projects = $this->facade->getLoader()->getProjectsFromCategory($category, $max_version, $limit, $offset, $flavor, $user);
264+
$response[] = $this->facade->getResponseManager()->createProjectCategoryResponse($projects, $category, $accept_language);
265+
}
266+
267+
$responseHeaders = [];
268+
$this->facade->getResponseManager()->addResponseHashToHeaders($responseHeaders, $response);
269+
$this->facade->getResponseManager()->addContentLanguageToHeaders($responseHeaders);
270+
271+
return [
272+
'response_code' => Response::HTTP_OK,
273+
'response_headers' => $responseHeaders,
274+
'response' => $response,
275+
];
276+
},
277+
3600 // 1 hour cache for categories
278+
);
256279

257-
foreach ($categories as $category) {
258-
$projects = $this->facade->getLoader()->getProjectsFromCategory($category, $max_version, $limit, $offset, $flavor, $user);
259-
$response[] = $this->facade->getResponseManager()->createProjectCategoryResponse($projects, $category, $accept_language);
260-
}
280+
$responseCode = $cached['response_code'];
281+
$responseHeaders = $cached['response_headers'];
261282

262-
$responseCode = Response::HTTP_OK;
263-
$this->facade->getResponseManager()->addResponseHashToHeaders($responseHeaders, $response);
264-
$this->facade->getResponseManager()->addContentLanguageToHeaders($responseHeaders);
265-
$this->facade->getResponseManager()->cacheResponse($cache_id, $responseCode, $responseHeaders, $response);
266-
267-
return $response;
283+
return $cached['response'];
268284
}
269285

270286
#[\Override]

src/Api/Services/Base/AbstractResponseManager.php

Lines changed: 116 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@
44

55
namespace App\Api\Services\Base;
66

7-
use App\Api\Services\ResponseCache\ResponseCacheManager;
8-
use App\DB\Entity\Api\ResponseCache;
97
use OpenAPI\Server\Service\SerializerInterface;
8+
use Psr\Cache\CacheItemInterface;
9+
use Psr\Cache\CacheItemPoolInterface;
10+
use Psr\Cache\InvalidArgumentException;
11+
use Symfony\Contracts\Cache\ItemInterface;
1012
use Symfony\Contracts\Translation\TranslatorInterface;
1113

1214
/**
@@ -16,10 +18,12 @@ abstract class AbstractResponseManager implements TranslatorAwareInterface
1618
{
1719
use TranslatorAwareTrait;
1820

21+
private const int DEFAULT_CACHE_TTL = 10800; // 3 hours in seconds
22+
1923
public function __construct(
2024
TranslatorInterface $translator,
2125
protected SerializerInterface $serializer,
22-
protected ResponseCacheManager $response_cache_manager,
26+
protected CacheItemPoolInterface $cache,
2327
) {
2428
$this->initTranslator($translator);
2529
}
@@ -41,48 +45,136 @@ public function addContentLanguageToHeaders(array &$responseHeaders): void
4145
}
4246

4347
/**
44-
* @throws \DateMalformedStringException
48+
* Get cached response data.
49+
*
50+
* @return array{response_code: int, response_headers: array, response: mixed}|null
51+
*
52+
* @throws InvalidArgumentException
4553
*/
46-
public function getCachedResponse(string $cache_id, string $time = '-180 minutes'): ?ResponseCache
54+
public function getCachedResponse(string $cache_id): ?array
4755
{
48-
/** @var ResponseCache|null $cache_entry */
49-
$cache_entry = $this->response_cache_manager->getResponseCacheRepository()->findOneBy(['id' => $cache_id]);
50-
if ('prod' !== $_ENV['APP_ENV']) {
51-
return null;
52-
}
53-
if (null === $cache_entry) {
56+
if ('prod' !== ($_ENV['APP_ENV'] ?? 'dev')) {
5457
return null;
5558
}
56-
if ($cache_entry->getCachedAt() <= new \DateTime($time)) {
57-
return null;
59+
60+
$item = $this->cache->getItem($this->sanitizeCacheKey($cache_id));
61+
62+
return $item->isHit() ? $item->get() : null;
63+
}
64+
65+
/**
66+
* Cache response data.
67+
*
68+
* @throws InvalidArgumentException
69+
*/
70+
public function cacheResponse(string $cache_id, int $response_code, array $responseHeaders, mixed $response, int $ttl = self::DEFAULT_CACHE_TTL): void
71+
{
72+
if ('prod' !== ($_ENV['APP_ENV'] ?? 'dev')) {
73+
return;
5874
}
5975

60-
return $cache_entry;
76+
$item = $this->cache->getItem($this->sanitizeCacheKey($cache_id));
77+
$item->set([
78+
'response_code' => $response_code,
79+
'response_headers' => $responseHeaders,
80+
'response' => $response,
81+
]);
82+
$item->expiresAfter($ttl);
83+
$this->cache->save($item);
6184
}
6285

6386
/**
64-
* @throws \JsonException
87+
* Get cached response or compute it.
88+
*
89+
* @param callable(CacheItemInterface): array{response_code: int, response_headers: array, response: mixed} $callback
90+
*
91+
* @return array{response_code: int, response_headers: array, response: mixed}
92+
*
93+
* @throws InvalidArgumentException
6594
*/
66-
public function cacheResponse(string $cache_id, int $response_code, array $responseHeaders, mixed $response): void
95+
public function getCachedOrCompute(string $cache_id, callable $callback, int $ttl = self::DEFAULT_CACHE_TTL): array
6796
{
68-
$this->response_cache_manager->addCacheEntry($cache_id, $response_code, $responseHeaders, $response);
97+
if ('prod' !== ($_ENV['APP_ENV'] ?? 'dev')) {
98+
// In non-prod environments, always compute without caching
99+
return $callback(new class implements CacheItemInterface, ItemInterface {
100+
public function getKey(): string
101+
{
102+
return '';
103+
}
104+
105+
public function get(): mixed
106+
{
107+
return null;
108+
}
109+
110+
public function isHit(): bool
111+
{
112+
return false;
113+
}
114+
115+
public function set(mixed $value): static
116+
{
117+
return $this;
118+
}
119+
120+
public function expiresAt(?\DateTimeInterface $expiration): static
121+
{
122+
return $this;
123+
}
124+
125+
public function expiresAfter(\DateInterval|int|null $time): static
126+
{
127+
return $this;
128+
}
129+
130+
public function getMetadata(): array
131+
{
132+
return [];
133+
}
134+
135+
public function tag(string|iterable $tags): static
136+
{
137+
return $this;
138+
}
139+
});
140+
}
141+
142+
$cache_key = $this->sanitizeCacheKey($cache_id);
143+
$item = $this->cache->getItem($cache_key);
144+
145+
if ($item->isHit()) {
146+
return $item->get();
147+
}
148+
149+
$result = $callback($item);
150+
$item->set($result);
151+
$item->expiresAfter($ttl);
152+
$this->cache->save($item);
153+
154+
return $result;
69155
}
70156

71-
protected function getSerializer(): SerializerInterface
157+
/**
158+
* Invalidate cache entry.
159+
*
160+
* @throws InvalidArgumentException
161+
*/
162+
public function invalidateCache(string $cache_id): void
72163
{
73-
return $this->serializer;
164+
$this->cache->deleteItem($this->sanitizeCacheKey($cache_id));
74165
}
75166

76-
public function extractResponseObject(ResponseCache $cache_entry): array
167+
protected function getSerializer(): SerializerInterface
77168
{
78-
return unserialize($cache_entry->getResponse()) ?? [];
169+
return $this->serializer;
79170
}
80171

81172
/**
82-
* @throws \JsonException
173+
* Sanitize cache key to be PSR-6 compliant.
83174
*/
84-
public function extractResponseHeader(ResponseCache $cache_entry): array
175+
private function sanitizeCacheKey(string $key): string
85176
{
86-
return json_decode($cache_entry->getResponseHeaders(), true, 512, JSON_THROW_ON_ERROR) ?? [];
177+
// PSR-6 forbids: {}()/\@:
178+
return str_replace(['/', '\\', '@', ':', '{', '}', '(', ')'], '_', $key);
87179
}
88180
}

src/Api/Services/MediaLibrary/MediaLibraryResponseManager.php

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
namespace App\Api\Services\MediaLibrary;
66

77
use App\Api\Services\Base\AbstractResponseManager;
8-
use App\Api\Services\ResponseCache\ResponseCacheManager;
98
use App\DB\Entity\MediaLibrary\MediaPackageCategory;
109
use App\DB\Entity\MediaLibrary\MediaPackageFile;
1110
use App\DB\EntityRepository\MediaLibrary\MediaPackageFileRepository;
@@ -20,12 +19,12 @@ class MediaLibraryResponseManager extends AbstractResponseManager
2019
public function __construct(
2120
TranslatorInterface $translator,
2221
SerializerInterface $serializer,
23-
ResponseCacheManager $response_cache_manager,
22+
\Psr\Cache\CacheItemPoolInterface|\Symfony\Contracts\Cache\CacheInterface $cache,
2423
private readonly UrlGeneratorInterface $url_generator,
2524
private readonly ParameterBagInterface $parameter_bag,
2625
private readonly MediaPackageFileRepository $media_package_file_repository,
2726
) {
28-
parent::__construct($translator, $serializer, $response_cache_manager);
27+
parent::__construct($translator, $serializer, $cache);
2928
}
3029

3130
public function createMediaFilesDataResponse(array $media_package_files, ?string $attributes): array
@@ -86,7 +85,8 @@ public function createMediaFileResponse(MediaPackageFile $media_package_file, ?s
8685
'theme' => $this->parameter_bag->get('umbrellaTheme'),
8786
'id' => $media_package_file->getId(),
8887
],
89-
UrlGeneratorInterface::ABSOLUTE_URL);
88+
UrlGeneratorInterface::ABSOLUTE_URL
89+
);
9090
}
9191

9292
if (in_array('size', $attributes_list, true)) {

0 commit comments

Comments
 (0)