From 4d530d72b5433a08fad3878e5b6653ad7455f5be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=A4u=C3=9Fler?= Date: Thu, 17 Mar 2022 21:27:10 +0100 Subject: [PATCH] [FEATURE] Enable basic HTTP caching and use Symfony's reverse proxy Related: #136 --- config/packages/framework.yaml | 1 + src/Controller/AbstractBadgeController.php | 29 ++++++- src/Controller/DownloadsBadgeController.php | 10 ++- src/Controller/ExtensionBadgeController.php | 10 ++- src/Controller/StabilityBadgeController.php | 10 ++- src/Controller/VersionBadgeController.php | 10 ++- src/Entity/Dto/ExtensionMetadata.php | 77 +++++++++++++++++++ src/Service/ApiService.php | 21 ++++-- tests/Entity/Dto/ExtensionMetadataTest.php | 83 +++++++++++++++++++++ tests/Service/ApiServiceTest.php | 4 +- 10 files changed, 233 insertions(+), 22 deletions(-) create mode 100644 src/Entity/Dto/ExtensionMetadata.php create mode 100644 tests/Entity/Dto/ExtensionMetadataTest.php diff --git a/config/packages/framework.yaml b/config/packages/framework.yaml index 465d5fd4f..608f837c4 100644 --- a/config/packages/framework.yaml +++ b/config/packages/framework.yaml @@ -26,6 +26,7 @@ framework: when@prod: framework: error_controller: App\Controller\ErrorController + http_cache: true when@test: framework: diff --git a/src/Controller/AbstractBadgeController.php b/src/Controller/AbstractBadgeController.php index e8f7ca4da..b36cf1860 100644 --- a/src/Controller/AbstractBadgeController.php +++ b/src/Controller/AbstractBadgeController.php @@ -47,14 +47,37 @@ public function setBadgeProviderFactory(BadgeProviderFactory $badgeProviderFacto $this->badgeProviderFactory = $badgeProviderFactory; } - protected function getBadgeResponse(Badge $badge, string $provider = null): Response - { + protected function getBadgeResponse( + Badge $badge, + string $provider = null, + \DateTime $cacheExpirationDate = null, + ): Response { try { $providerClass = $this->badgeProviderFactory->get($provider); } catch (InvalidProviderException $e) { throw new NotFoundHttpException($e->getMessage()); } - return $providerClass->createResponse($badge); + $response = $providerClass->createResponse($badge); + + if (null !== $cacheExpirationDate) { + $this->applyCacheHeaders($response, $cacheExpirationDate); + } + + return $response; + } + + private function applyCacheHeaders(Response $response, \DateTime $cacheExpirationDate): void + { + $expiresAfter = max(0, $cacheExpirationDate->getTimestamp() - time()); + + // Early return if cache is expired + if (0 === $expiresAfter) { + return; + } + + $response->setPublic(); + $response->setMaxAge($expiresAfter); + $response->headers->addCacheControlDirective('must-revalidate', true); } } diff --git a/src/Controller/DownloadsBadgeController.php b/src/Controller/DownloadsBadgeController.php index 19814b132..088d324d9 100644 --- a/src/Controller/DownloadsBadgeController.php +++ b/src/Controller/DownloadsBadgeController.php @@ -52,10 +52,14 @@ public function __construct( public function __invoke(Request $request, string $extension, string $provider = null): Response { - $apiResponse = $this->apiService->getExtensionMetadata($extension); - $downloads = $apiResponse[0]['downloads'] + $extensionMetadata = $this->apiService->getExtensionMetadata($extension); + $downloads = $extensionMetadata[0]['downloads'] ?? throw new BadRequestHttpException('Invalid API response.'); - return $this->getBadgeResponse(Badge::forDownloads($downloads), $provider); + return $this->getBadgeResponse( + Badge::forDownloads($downloads), + $provider, + $extensionMetadata->getExpiryDate(), + ); } } diff --git a/src/Controller/ExtensionBadgeController.php b/src/Controller/ExtensionBadgeController.php index 91c535ddd..9d7281016 100644 --- a/src/Controller/ExtensionBadgeController.php +++ b/src/Controller/ExtensionBadgeController.php @@ -52,10 +52,14 @@ public function __construct( public function __invoke(Request $request, string $extension, string $provider = null): Response { - $apiResponse = $this->apiService->getExtensionMetadata($extension); - $extensionKey = $apiResponse[0]['key'] + $extensionMetadata = $this->apiService->getExtensionMetadata($extension); + $extensionKey = $extensionMetadata[0]['key'] ?? throw new BadRequestHttpException('Invalid API response.'); - return $this->getBadgeResponse(Badge::forExtension($extensionKey), $provider); + return $this->getBadgeResponse( + Badge::forExtension($extensionKey), + $provider, + $extensionMetadata->getExpiryDate(), + ); } } diff --git a/src/Controller/StabilityBadgeController.php b/src/Controller/StabilityBadgeController.php index 75fc2f078..25ebb840f 100644 --- a/src/Controller/StabilityBadgeController.php +++ b/src/Controller/StabilityBadgeController.php @@ -52,10 +52,14 @@ public function __construct( public function __invoke(Request $request, string $extension, string $provider = null): Response { - $apiResponse = $this->apiService->getExtensionMetadata($extension); - $stability = $apiResponse[0]['current_version']['state'] + $extensionMetadata = $this->apiService->getExtensionMetadata($extension); + $stability = $extensionMetadata[0]['current_version']['state'] ?? throw new BadRequestHttpException('Invalid API response.'); - return $this->getBadgeResponse(Badge::forStability($stability), $provider); + return $this->getBadgeResponse( + Badge::forStability($stability), + $provider, + $extensionMetadata->getExpiryDate(), + ); } } diff --git a/src/Controller/VersionBadgeController.php b/src/Controller/VersionBadgeController.php index e1023060b..ec3c1ffa5 100644 --- a/src/Controller/VersionBadgeController.php +++ b/src/Controller/VersionBadgeController.php @@ -52,10 +52,14 @@ public function __construct( public function __invoke(Request $request, string $extension, string $provider = null): Response { - $apiResponse = $this->apiService->getExtensionMetadata($extension); - $version = $apiResponse[0]['current_version']['number'] + $extensionMetadata = $this->apiService->getExtensionMetadata($extension); + $version = $extensionMetadata[0]['current_version']['number'] ?? throw new BadRequestHttpException('Invalid API response.'); - return $this->getBadgeResponse(Badge::forVersion($version), $provider); + return $this->getBadgeResponse( + Badge::forVersion($version), + $provider, + $extensionMetadata->getExpiryDate() + ); } } diff --git a/src/Entity/Dto/ExtensionMetadata.php b/src/Entity/Dto/ExtensionMetadata.php new file mode 100644 index 000000000..7943c0b87 --- /dev/null +++ b/src/Entity/Dto/ExtensionMetadata.php @@ -0,0 +1,77 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +namespace App\Entity\Dto; + +/** + * ExtensionMetadata. + * + * @author Elias Häußler + * @license GPL-3.0-or-later + * + * @implements \ArrayAccess + */ +final class ExtensionMetadata implements \ArrayAccess +{ + public function __construct( + /** + * @var array + */ + private array $metadata, + private ?\DateTime $expiryDate = null, + ) { + } + + /** + * @return array + */ + public function getMetadata(): array + { + return $this->metadata; + } + + public function getExpiryDate(): ?\DateTime + { + return $this->expiryDate; + } + + public function offsetExists(mixed $offset): bool + { + return isset($this->metadata[$offset]); + } + + public function offsetGet(mixed $offset): mixed + { + return $this->metadata[$offset] ?? null; + } + + public function offsetSet(mixed $offset, mixed $value): void + { + $this->metadata[$offset] = $value; + } + + public function offsetUnset(mixed $offset): void + { + unset($this->metadata[$offset]); + } +} diff --git a/src/Service/ApiService.php b/src/Service/ApiService.php index 473e6f6d6..5c21c03ee 100644 --- a/src/Service/ApiService.php +++ b/src/Service/ApiService.php @@ -23,6 +23,7 @@ namespace App\Service; +use App\Entity\Dto\ExtensionMetadata; use Symfony\Contracts\Cache\CacheInterface; use Symfony\Contracts\Cache\ItemInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; @@ -42,17 +43,27 @@ public function __construct( ) { } - /** - * @return array - */ - public function getExtensionMetadata(string $extension): array + public function getExtensionMetadata(string $extension): ExtensionMetadata { $apiPath = $this->buildApiPath('/extension/{extension}', ['extension' => $extension]); - return $this->cache->get( + // Fetch extension metadata from cache or external API + $extensionMetadata = $this->cache->get( $this->calculateCacheIdentifier('typo3_api.extension_metadata', ['apiPath' => $apiPath]), fn (ItemInterface $item) => $this->sendRequestAndCacheResponse($apiPath, $item), + null, + $cacheMetadata, ); + + // Define cache expiry date from cache metadata + if (isset($cacheMetadata[ItemInterface::METADATA_EXPIRY])) { + $timestamp = (int) $cacheMetadata[ItemInterface::METADATA_EXPIRY]; + $expiryDate = \DateTime::createFromFormat('U', (string) $timestamp) ?: null; + } else { + $expiryDate = null; + } + + return new ExtensionMetadata($extensionMetadata, $expiryDate); } /** diff --git a/tests/Entity/Dto/ExtensionMetadataTest.php b/tests/Entity/Dto/ExtensionMetadataTest.php new file mode 100644 index 000000000..ea84bfd77 --- /dev/null +++ b/tests/Entity/Dto/ExtensionMetadataTest.php @@ -0,0 +1,83 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +namespace App\Tests\Entity\Dto; + +use App\Entity\Dto\ExtensionMetadata; +use PHPUnit\Framework\TestCase; + +/** + * ExtensionMetadataTest. + * + * @author Elias Häußler + * @license GPL-3.0-or-later + */ +final class ExtensionMetadataTest extends TestCase +{ + private \DateTime $expiryDate; + private ExtensionMetadata $subject; + + protected function setUp(): void + { + $this->expiryDate = new \DateTime(); + $this->subject = new ExtensionMetadata(['foo' => 'baz'], $this->expiryDate); + } + + /** + * @test + */ + public function getMetadataReturnsExtensionMetadata(): void + { + self::assertSame(['foo' => 'baz'], $this->subject->getMetadata()); + } + + /** + * @test + */ + public function getExpiryDateReturnsExpiryDate(): void + { + self::assertSame($this->expiryDate, $this->subject->getExpiryDate()); + } + + /** + * @test + */ + public function subjectCanBeAccessedAsArray(): void + { + // offsetExists() + self::assertTrue(isset($this->subject['foo'])); + self::assertFalse(isset($this->subject['baz'])); + + // offsetGet() + self::assertSame('baz', $this->subject['foo']); + self::assertNull($this->subject['baz']); + + // offsetSet() + $this->subject['baz'] = 'foo'; + self::assertSame('foo', $this->subject['baz']); + + // offsetUnset() + unset($this->subject['baz']); + self::assertFalse(isset($this->subject['baz'])); + } +} diff --git a/tests/Service/ApiServiceTest.php b/tests/Service/ApiServiceTest.php index ee63e15b0..d0b3f5a87 100644 --- a/tests/Service/ApiServiceTest.php +++ b/tests/Service/ApiServiceTest.php @@ -43,7 +43,7 @@ public function getExtensionMetadataReturnsMetadataFromCache(): void $this->cache->get($cacheIdentifier, fn () => ['foo' => 'baz']); - self::assertSame(['foo' => 'baz'], $this->apiService->getExtensionMetadata('foo')); + self::assertSame(['foo' => 'baz'], $this->apiService->getExtensionMetadata('foo')->getMetadata()); self::assertSame(0, $this->client->getRequestsCount()); $this->cache->delete($cacheIdentifier); @@ -56,7 +56,7 @@ public function getExtensionMetadataReturnsMetadataFromApiAndStoresResponseInCac { $this->mockResponses[] = new MockResponse(json_encode(['foo' => 'baz'], JSON_THROW_ON_ERROR)); - self::assertSame(['foo' => 'baz'], $this->apiService->getExtensionMetadata('foo')); + self::assertSame(['foo' => 'baz'], $this->apiService->getExtensionMetadata('foo')->getMetadata()); self::assertSame(1, $this->client->getRequestsCount()); self::assertSame(['foo' => 'baz'], $this->cache->get($this->getCacheIdentifier(), fn () => null)); }