From acd4fa7d903dcddac604eed94bc381423c63f53a Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Thu, 7 Jul 2022 17:17:41 +0200 Subject: [PATCH] [HttpKernel] Add `#[Cache]` to describe the default HTTP cache headers on controllers --- .../FrameworkBundle/Resources/config/web.php | 5 + .../Component/HttpKernel/Attribute/Cache.php | 84 +++++ src/Symfony/Component/HttpKernel/CHANGELOG.md | 1 + .../ArgumentMetadataFactory.php | 2 +- .../HttpKernel/Event/ControllerEvent.php | 14 +- .../EventListener/CacheAttributeListener.php | 193 +++++++++++ .../CacheAttributeListenerTest.php | 324 ++++++++++++++++++ .../Controller/CacheAttributeController.php | 30 ++ 8 files changed, 650 insertions(+), 3 deletions(-) create mode 100644 src/Symfony/Component/HttpKernel/Attribute/Cache.php create mode 100644 src/Symfony/Component/HttpKernel/EventListener/CacheAttributeListener.php create mode 100644 src/Symfony/Component/HttpKernel/Tests/EventListener/CacheAttributeListenerTest.php create mode 100644 src/Symfony/Component/HttpKernel/Tests/Fixtures/Controller/CacheAttributeController.php diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/web.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/web.php index d28e88995f84c..82ceb2e077da4 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/web.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/web.php @@ -24,6 +24,7 @@ use Symfony\Component\HttpKernel\Controller\ArgumentResolver\VariadicValueResolver; use Symfony\Component\HttpKernel\Controller\ErrorController; use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadataFactory; +use Symfony\Component\HttpKernel\EventListener\CacheAttributeListener; use Symfony\Component\HttpKernel\EventListener\DisallowRobotsIndexingListener; use Symfony\Component\HttpKernel\EventListener\ErrorListener; use Symfony\Component\HttpKernel\EventListener\LocaleListener; @@ -117,5 +118,9 @@ ]) ->tag('kernel.event_subscriber') ->tag('monolog.logger', ['channel' => 'request']) + + ->set('controller.cache_attribute_listener', CacheAttributeListener::class) + ->tag('kernel.event_subscriber') + ; }; diff --git a/src/Symfony/Component/HttpKernel/Attribute/Cache.php b/src/Symfony/Component/HttpKernel/Attribute/Cache.php new file mode 100644 index 0000000000000..e51545feb3c03 --- /dev/null +++ b/src/Symfony/Component/HttpKernel/Attribute/Cache.php @@ -0,0 +1,84 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpKernel\Attribute; + +/** + * Describes the default HTTP cache headers on controllers. + * + * @author Fabien Potencier + */ +#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::TARGET_FUNCTION)] +final class Cache +{ + public function __construct( + /** + * The expiration date as a valid date for the strtotime() function. + */ + public ?string $expires = null, + + /** + * The number of seconds that the response is considered fresh by a private + * cache like a web browser. + */ + public int|string|null $maxage = null, + + /** + * The number of seconds that the response is considered fresh by a public + * cache like a reverse proxy cache. + */ + public int|string|null $smaxage = null, + + /** + * Whether the response is public or not. + */ + public ?bool $public = null, + + /** + * Whether or not the response must be revalidated. + */ + public bool $mustRevalidate = false, + + /** + * Additional "Vary:"-headers. + */ + public array $vary = [], + + /** + * An expression to compute the Last-Modified HTTP header. + */ + public ?string $lastModified = null, + + /** + * An expression to compute the ETag HTTP header. + */ + public ?string $etag = null, + + /** + * max-stale Cache-Control header + * It can be expressed in seconds or with a relative time format (1 day, 2 weeks, ...). + */ + public int|string|null $maxStale = null, + + /** + * stale-while-revalidate Cache-Control header + * It can be expressed in seconds or with a relative time format (1 day, 2 weeks, ...). + */ + public int|string|null $staleWhileRevalidate = null, + + /** + * stale-if-error Cache-Control header + * It can be expressed in seconds or with a relative time format (1 day, 2 weeks, ...). + */ + public int|string|null $staleIfError = null, + ) { + } +} diff --git a/src/Symfony/Component/HttpKernel/CHANGELOG.md b/src/Symfony/Component/HttpKernel/CHANGELOG.md index c79f5150a02ed..9ccb0c10e0770 100644 --- a/src/Symfony/Component/HttpKernel/CHANGELOG.md +++ b/src/Symfony/Component/HttpKernel/CHANGELOG.md @@ -6,6 +6,7 @@ CHANGELOG * Add constructor argument `bool $catchThrowable` to `HttpKernel` * Add `ControllerEvent::getAttributes()` to handle attributes on controllers + * Add `#[Cache]` to describe the default HTTP cache headers on controllers 6.1 --- diff --git a/src/Symfony/Component/HttpKernel/ControllerMetadata/ArgumentMetadataFactory.php b/src/Symfony/Component/HttpKernel/ControllerMetadata/ArgumentMetadataFactory.php index b6f1580b03134..de5c00011a684 100644 --- a/src/Symfony/Component/HttpKernel/ControllerMetadata/ArgumentMetadataFactory.php +++ b/src/Symfony/Component/HttpKernel/ControllerMetadata/ArgumentMetadataFactory.php @@ -21,7 +21,7 @@ final class ArgumentMetadataFactory implements ArgumentMetadataFactoryInterface /** * {@inheritdoc} */ - public function createArgumentMetadata(string|object|array $controller, \ReflectionClass $class = null, \ReflectionFunction $reflection = null): array + public function createArgumentMetadata(string|object|array $controller, \ReflectionClass $class = null, \ReflectionFunctionAbstract $reflection = null): array { $arguments = []; diff --git a/src/Symfony/Component/HttpKernel/Event/ControllerEvent.php b/src/Symfony/Component/HttpKernel/Event/ControllerEvent.php index 6d8283325fd0a..b1eceb4ca247b 100644 --- a/src/Symfony/Component/HttpKernel/Event/ControllerEvent.php +++ b/src/Symfony/Component/HttpKernel/Event/ControllerEvent.php @@ -61,8 +61,18 @@ public function setController(callable $controller, array $attributes = null): v unset($this->attributes); } - $action = new \ReflectionFunction($controller(...)); - $this->getRequest()->attributes->set('_controller_reflectors', [str_contains($action->name, '{closure}') ? null : $action->getClosureScopeClass(), $action]); + if (\is_array($controller) && method_exists(...$controller)) { + $action = new \ReflectionMethod(...$controller); + $class = new \ReflectionClass($controller[0]); + } elseif (\is_string($controller) && false !== $i = strpos($controller, '::')) { + $action = new \ReflectionMethod($controller); + $class = new \ReflectionClass(substr($controller, 0, $i)); + } else { + $action = new \ReflectionFunction($controller(...)); + $class = str_contains($action->name, '{closure}') ? null : $action->getClosureScopeClass(); + } + + $this->getRequest()->attributes->set('_controller_reflectors', [$class, $action]); $this->controller = $controller; } diff --git a/src/Symfony/Component/HttpKernel/EventListener/CacheAttributeListener.php b/src/Symfony/Component/HttpKernel/EventListener/CacheAttributeListener.php new file mode 100644 index 0000000000000..0d3303cc467af --- /dev/null +++ b/src/Symfony/Component/HttpKernel/EventListener/CacheAttributeListener.php @@ -0,0 +1,193 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpKernel\EventListener; + +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\ExpressionLanguage\ExpressionLanguage; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Attribute\Cache; +use Symfony\Component\HttpKernel\Event\ControllerEvent; +use Symfony\Component\HttpKernel\Event\ResponseEvent; +use Symfony\Component\HttpKernel\KernelEvents; + +/** + * Handles HTTP cache headers configured via the Cache attribute. + * + * @author Fabien Potencier + */ +class CacheAttributeListener implements EventSubscriberInterface +{ + /** + * @var \SplObjectStorage + */ + private \SplObjectStorage $lastModified; + + /** + * @var \SplObjectStorage + */ + private \SplObjectStorage $etags; + + public function __construct( + private ?ExpressionLanguage $expressionLanguage = null, + ) { + $this->lastModified = new \SplObjectStorage(); + $this->etags = new \SplObjectStorage(); + } + + /** + * Handles HTTP validation headers. + */ + public function onKernelController(ControllerEvent $event) + { + $request = $event->getRequest(); + + if (!\is_array($attributes = $request->attributes->get('_cache') ?? $event->getAttributes()[Cache::class] ?? null)) { + return; + } + + $request->attributes->set('_cache', $attributes); + $response = null; + $lastModified = null; + $etag = null; + + /** @var Cache[] $attributes */ + foreach ($attributes as $cache) { + if (null !== $cache->lastModified) { + $lastModified = $this->getExpressionLanguage()->evaluate($cache->lastModified, $request->attributes->all()); + ($response ??= new Response())->setLastModified($lastModified); + } + + if (null !== $cache->etag) { + $etag = hash('sha256', $this->getExpressionLanguage()->evaluate($cache->etag, $request->attributes->all())); + ($response ??= new Response())->setEtag($etag); + } + } + + if ($response?->isNotModified($request)) { + $event->setController(static fn () => $response); + $event->stopPropagation(); + + return; + } + + if (null !== $etag) { + $this->etags[$request] = $etag; + } + if (null !== $lastModified) { + $this->lastModified[$request] = $lastModified; + } + } + + /** + * Modifies the response to apply HTTP cache headers when needed. + */ + public function onKernelResponse(ResponseEvent $event) + { + $request = $event->getRequest(); + + /** @var Cache[] $attributes */ + if (!\is_array($attributes = $request->attributes->get('_cache'))) { + return; + } + $response = $event->getResponse(); + + // http://tools.ietf.org/html/draft-ietf-httpbis-p4-conditional-12#section-3.1 + if (!\in_array($response->getStatusCode(), [200, 203, 300, 301, 302, 304, 404, 410])) { + unset($this->lastModified[$request]); + unset($this->etags[$request]); + + return; + } + + if (isset($this->lastModified[$request]) && !$response->headers->has('Last-Modified')) { + $response->setLastModified($this->lastModified[$request]); + } + + if (isset($this->etags[$request]) && !$response->headers->has('Etag')) { + $response->setEtag($this->etags[$request]); + } + + unset($this->lastModified[$request]); + unset($this->etags[$request]); + $hasVary = $response->headers->has('Vary'); + + foreach (array_reverse($attributes) as $cache) { + if (null !== $cache->smaxage && !$response->headers->hasCacheControlDirective('s-maxage')) { + $response->setSharedMaxAge($this->toSeconds($cache->smaxage)); + } + + if ($cache->mustRevalidate) { + $response->headers->addCacheControlDirective('must-revalidate'); + } + + if (null !== $cache->maxage && !$response->headers->hasCacheControlDirective('max-age')) { + $response->setMaxAge($this->toSeconds($cache->maxage)); + } + + if (null !== $cache->maxStale && !$response->headers->hasCacheControlDirective('max-stale')) { + $response->headers->addCacheControlDirective('max-stale', $this->toSeconds($cache->maxStale)); + } + + if (null !== $cache->staleWhileRevalidate && !$response->headers->hasCacheControlDirective('stale-while-revalidate')) { + $response->headers->addCacheControlDirective('stale-while-revalidate', $this->toSeconds($cache->staleWhileRevalidate)); + } + + if (null !== $cache->staleIfError && !$response->headers->hasCacheControlDirective('stale-if-error')) { + $response->headers->addCacheControlDirective('stale-if-error', $this->toSeconds($cache->staleIfError)); + } + + if (null !== $cache->expires && !$response->headers->has('Expires')) { + $response->setExpires(new \DateTimeImmutable('@'.strtotime($cache->expires, time()))); + } + + if (!$hasVary && $cache->vary) { + $response->setVary($cache->vary, false); + } + } + + foreach ($attributes as $cache) { + if (true === $cache->public) { + $response->setPublic(); + } + + if (false === $cache->public) { + $response->setPrivate(); + } + } + } + + public static function getSubscribedEvents(): array + { + return [ + KernelEvents::CONTROLLER => ['onKernelController', 10], + KernelEvents::RESPONSE => ['onKernelResponse', -10], + ]; + } + + private function getExpressionLanguage(): ExpressionLanguage + { + return $this->expressionLanguage ??= class_exists(ExpressionLanguage::class) + ? new ExpressionLanguage() + : throw new \LogicException('Unable to use expressions as the Symfony ExpressionLanguage component is not installed. Try running "composer require symfony/expression-language".'); + } + + private function toSeconds(int|string $time): int + { + if (!is_numeric($time)) { + $now = time(); + $time = strtotime($time, $now) - $now; + } + + return $time; + } +} diff --git a/src/Symfony/Component/HttpKernel/Tests/EventListener/CacheAttributeListenerTest.php b/src/Symfony/Component/HttpKernel/Tests/EventListener/CacheAttributeListenerTest.php new file mode 100644 index 0000000000000..f49d5b4cc7511 --- /dev/null +++ b/src/Symfony/Component/HttpKernel/Tests/EventListener/CacheAttributeListenerTest.php @@ -0,0 +1,324 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpKernel\Tests\EventListener; + +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Attribute\Cache; +use Symfony\Component\HttpKernel\Event\ControllerEvent; +use Symfony\Component\HttpKernel\Event\ResponseEvent; +use Symfony\Component\HttpKernel\EventListener\CacheAttributeListener; +use Symfony\Component\HttpKernel\HttpKernelInterface; +use Symfony\Component\HttpKernel\Tests\Fixtures\Controller\CacheAttributeController; + +class CacheAttributeListenerTest extends TestCase +{ + protected function setUp(): void + { + $this->listener = new CacheAttributeListener(); + $this->response = new Response(); + $this->cache = new Cache(); + $this->request = $this->createRequest($this->cache); + $this->event = $this->createEventMock($this->request, $this->response); + } + + public function testWontReassignResponseWhenResponseIsUnsuccessful() + { + $response = $this->event->getResponse(); + + $this->response->setStatusCode(500); + + $this->listener->onKernelResponse($this->event); + + $this->assertSame($response, $this->event->getResponse()); + } + + public function testWontReassignResponseWhenNoConfigurationIsPresent() + { + $response = $this->event->getResponse(); + + $this->request->attributes->remove('_cache'); + + $this->listener->onKernelResponse($this->event); + + $this->assertSame($response, $this->event->getResponse()); + } + + public function testResponseIsPublicIfSharedMaxAgeSetAndPublicNotOverridden() + { + $request = $this->createRequest(new Cache(smaxage: 1)); + + $this->listener->onKernelResponse($this->createEventMock($request, $this->response)); + + $this->assertTrue($this->response->headers->hasCacheControlDirective('public')); + $this->assertFalse($this->response->headers->hasCacheControlDirective('private')); + } + + public function testResponseIsPublicIfConfigurationIsPublicTrue() + { + $request = $this->createRequest(new Cache(public: true)); + + $this->listener->onKernelResponse($this->createEventMock($request, $this->response)); + + $this->assertTrue($this->response->headers->hasCacheControlDirective('public')); + $this->assertFalse($this->response->headers->hasCacheControlDirective('private')); + } + + public function testResponseIsPrivateIfConfigurationIsPublicFalse() + { + $request = $this->createRequest(new Cache(public: false)); + + $this->listener->onKernelResponse($this->createEventMock($request, $this->response)); + + $this->assertFalse($this->response->headers->hasCacheControlDirective('public')); + $this->assertTrue($this->response->headers->hasCacheControlDirective('private')); + } + + public function testResponseVary() + { + $vary = ['foobar']; + $request = $this->createRequest(new Cache(vary: $vary)); + + $this->listener->onKernelResponse($this->createEventMock($request, $this->response)); + $this->assertTrue($this->response->hasVary()); + $result = $this->response->getVary(); + $this->assertSame($vary, $result); + } + + public function testResponseVaryWhenVaryNotSet() + { + $request = $this->createRequest(new Cache()); + $vary = ['foobar']; + $this->response->setVary($vary); + + $this->listener->onKernelResponse($this->createEventMock($request, $this->response)); + $this->assertTrue($this->response->hasVary()); + $result = $this->response->getVary(); + $this->assertNotEmpty($result, 'Existing vary headers should not be removed'); + $this->assertSame($vary, $result, 'Vary header should not be changed'); + } + + public function testResponseIsPrivateIfConfigurationIsPublicNotSet() + { + $request = $this->createRequest(new Cache()); + + $this->listener->onKernelResponse($this->createEventMock($request, $this->response)); + + $this->assertFalse($this->response->headers->hasCacheControlDirective('public')); + } + + public function testAttributeConfigurationsAreSetOnResponse() + { + $this->assertNull($this->response->getMaxAge()); + $this->assertNull($this->response->getExpires()); + $this->assertFalse($this->response->headers->hasCacheControlDirective('s-maxage')); + $this->assertFalse($this->response->headers->hasCacheControlDirective('max-stale')); + $this->assertFalse($this->response->headers->hasCacheControlDirective('stale-while-revalidate')); + $this->assertFalse($this->response->headers->hasCacheControlDirective('stale-if-error')); + + $this->request->attributes->set('_cache', [new Cache( + expires: 'tomorrow', + maxage: '15', + smaxage: '15', + maxStale: '5', + staleWhileRevalidate: '6', + staleIfError: '7', + )]); + + $this->listener->onKernelResponse($this->event); + + $this->assertSame(15, $this->response->getMaxAge()); + $this->assertSame('15', $this->response->headers->getCacheControlDirective('s-maxage')); + $this->assertSame('5', $this->response->headers->getCacheControlDirective('max-stale')); + $this->assertSame('6', $this->response->headers->getCacheControlDirective('stale-while-revalidate')); + $this->assertSame('7', $this->response->headers->getCacheControlDirective('stale-if-error')); + $this->assertInstanceOf(\DateTime::class, $this->response->getExpires()); + } + + public function testCacheMaxAgeSupportsStrtotimeFormat() + { + $this->request->attributes->set('_cache', [new Cache( + maxage: '1 day', + smaxage: '1 day', + maxStale: '1 day', + staleWhileRevalidate: '1 day', + staleIfError: '1 day', + )]); + + $this->listener->onKernelResponse($this->event); + + $this->assertSame('86400', $this->response->headers->getCacheControlDirective('s-maxage')); + $this->assertSame(86400, $this->response->getMaxAge()); + $this->assertSame('86400', $this->response->headers->getCacheControlDirective('max-stale')); + $this->assertSame('86400', $this->response->headers->getCacheControlDirective('stale-if-error')); + } + + public function testLastModifiedNotModifiedResponse() + { + $request = $this->createRequest(new Cache(lastModified: 'test.getDate()')); + $request->attributes->set('test', new TestEntity()); + $request->headers->add(['If-Modified-Since' => 'Fri, 23 Aug 2013 00:00:00 GMT']); + + $listener = new CacheAttributeListener(); + $controllerEvent = new ControllerEvent($this->getKernel(), function () { + return new Response(); + }, $request, null); + + $listener->onKernelController($controllerEvent); + $response = \call_user_func($controllerEvent->getController()); + + $this->assertSame(304, $response->getStatusCode()); + } + + public function testLastModifiedHeader() + { + $request = $this->createRequest(new Cache(lastModified: 'test.getDate()')); + $request->attributes->set('test', new TestEntity()); + + $listener = new CacheAttributeListener(); + $controllerEvent = new ControllerEvent($this->getKernel(), function () { + return new Response(); + }, $request, null); + $listener->onKernelController($controllerEvent); + + $responseEvent = new ResponseEvent($this->getKernel(), $request, HttpKernelInterface::MAIN_REQUEST, \call_user_func($controllerEvent->getController())); + $listener->onKernelResponse($responseEvent); + + $response = $responseEvent->getResponse(); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertTrue($response->headers->has('Last-Modified')); + $this->assertSame('Fri, 23 Aug 2013 00:00:00 GMT', $response->headers->get('Last-Modified')); + } + + public function testEtagNotModifiedResponse() + { + $request = $this->createRequest(new Cache(etag: 'test.getId()')); + $request->attributes->set('test', $entity = new TestEntity()); + $request->headers->add(['If-None-Match' => sprintf('"%s"', hash('sha256', $entity->getId()))]); + + $listener = new CacheAttributeListener(); + $controllerEvent = new ControllerEvent($this->getKernel(), function () { + return new Response(); + }, $request, null); + + $listener->onKernelController($controllerEvent); + $response = \call_user_func($controllerEvent->getController()); + + $this->assertSame(304, $response->getStatusCode()); + } + + public function testEtagHeader() + { + $request = $this->createRequest(new Cache(etag: 'test.getId()')); + $request->attributes->set('test', $entity = new TestEntity()); + + $listener = new CacheAttributeListener(); + $controllerEvent = new ControllerEvent($this->getKernel(), function () { + return new Response(); + }, $request, null); + $listener->onKernelController($controllerEvent); + + $responseEvent = new ResponseEvent($this->getKernel(), $request, HttpKernelInterface::MAIN_REQUEST, \call_user_func($controllerEvent->getController())); + $listener->onKernelResponse($responseEvent); + + $response = $responseEvent->getResponse(); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertTrue($response->headers->has('Etag')); + $this->assertStringContainsString(hash('sha256', $entity->getId()), $response->headers->get('Etag')); + } + + public function testConfigurationDoesNotOverrideAlreadySetResponseHeaders() + { + $request = $this->createRequest(new Cache( + expires: 'Fri, 24 Aug 2013 00:00:00 GMT', + maxage: '15', + smaxage: '15', + vary: ['foobar'], + lastModified: 'Fri, 24 Aug 2013 00:00:00 GMT', + etag: '"12345"', + )); + + $response = new Response(); + $response->setEtag('"54321"'); + $response->setLastModified(new \DateTime('Fri, 23 Aug 2014 00:00:00 GMT')); + $response->setExpires(new \DateTime('Fri, 24 Aug 2014 00:00:00 GMT')); + $response->setSharedMaxAge(30); + $response->setMaxAge(30); + $response->setVary(['foobaz']); + + $listener = new CacheAttributeListener(); + $responseEvent = new ResponseEvent($this->getKernel(), $request, HttpKernelInterface::MAIN_REQUEST, $response); + $listener->onKernelResponse($responseEvent); + + $this->assertSame('"54321"', $response->getEtag()); + $this->assertEquals(new \DateTime('Fri, 23 Aug 2014 00:00:00 GMT'), $response->getLastModified()); + $this->assertEquals(new \DateTime('Fri, 24 Aug 2014 00:00:00 GMT'), $response->getExpires()); + $this->assertSame('30', $response->headers->getCacheControlDirective('s-maxage')); + $this->assertSame(30, $response->getMaxAge()); + $this->assertSame(['foobaz'], $response->getVary()); + } + + public function testAttribute() + { + $request = new Request(); + $event = new ControllerEvent($this->getKernel(), [new CacheAttributeController(), 'foo'], $request, null); + $this->listener->onKernelController($event); + + $response = new Response(); + $event = new ResponseEvent($this->getKernel(), $request, HttpKernelInterface::MAIN_REQUEST, $response); + $this->listener->onKernelResponse($event); + + $this->assertSame(CacheAttributeController::METHOD_SMAXAGE, $response->getMaxAge()); + + $request = new Request(); + $event = new ControllerEvent($this->getKernel(), [new CacheAttributeController(), 'bar'], $request, null); + $this->listener->onKernelController($event); + + $response = new Response(); + $event = new ResponseEvent($this->getKernel(), $request, HttpKernelInterface::MAIN_REQUEST, $response); + $this->listener->onKernelResponse($event); + + $this->assertSame(CacheAttributeController::CLASS_SMAXAGE, $response->getMaxAge()); + } + + private function createRequest(Cache $cache = null) + { + return new Request([], [], ['_cache' => [$cache]]); + } + + private function createEventMock(Request $request, Response $response) + { + return new ResponseEvent($this->getKernel(), $request, HttpKernelInterface::MAIN_REQUEST, $response); + } + + private function getKernel(): MockObject&HttpKernelInterface + { + return $this->getMockBuilder(HttpKernelInterface::class)->getMock(); + } +} + +class TestEntity +{ + public function getDate() + { + return new \DateTime('Fri, 23 Aug 2013 00:00:00 GMT'); + } + + public function getId() + { + return '12345'; + } +} diff --git a/src/Symfony/Component/HttpKernel/Tests/Fixtures/Controller/CacheAttributeController.php b/src/Symfony/Component/HttpKernel/Tests/Fixtures/Controller/CacheAttributeController.php new file mode 100644 index 0000000000000..eac47e562401c --- /dev/null +++ b/src/Symfony/Component/HttpKernel/Tests/Fixtures/Controller/CacheAttributeController.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpKernel\Tests\Fixtures\Controller; + +use Symfony\Component\HttpKernel\Attribute\Cache; + +#[Cache(smaxage: 20)] +class CacheAttributeController +{ + public const CLASS_SMAXAGE = 20; + public const METHOD_SMAXAGE = 25; + + #[Cache(smaxage: 25)] + public function foo() + { + } + + public function bar() + { + } +}