Skip to content

Commit d3e3733

Browse files
silverbackdandunglas
authored andcommitted
Ability to modify response headers (mainly cache related headers) (#2288)
* Allow user-defined cache headers A user may wish to define response cache headers in a custom controller or per resource. E.g. a /entity/random endpoint should have a max-age of 0 * Override cache max-age and shared-max-age Add annotation attributes for cache_headers which allows max_age and shared_max_age to be assigned per resource. * Remove unused property The property was added when I thought it may be good to have the option outside of 'attributes' - decided to put the option in attributes instead and failed to remove this property. * Code style fixes * Test code style fix * Review changes applied - Order of constructor arguments + default null value - Remove 'throws' annotation - setting $resourceCacheHeaders with default fallback to empty array - Improved conditional statements * Add cacheHeaders Attribute annotation * Accidental typo * Alphabetical order Updated attribute annotation to alphabetical order - moved the property into alphabetical order as well
1 parent d43c383 commit d3e3733

File tree

6 files changed

+77
-10
lines changed

6 files changed

+77
-10
lines changed

src/Annotation/ApiResource.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
* @Attribute("accessControl", type="string"),
2727
* @Attribute("accessControlMessage", type="string"),
2828
* @Attribute("attributes", type="array"),
29+
* @Attribute("cacheHeaders", type="array"),
2930
* @Attribute("collectionOperations", type="array"),
3031
* @Attribute("denormalizationContext", type="array"),
3132
* @Attribute("deprecationReason", type="string"),
@@ -109,6 +110,13 @@ final class ApiResource
109110
*/
110111
private $accessControlMessage;
111112

113+
/**
114+
* @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112
115+
*
116+
* @var array
117+
*/
118+
private $cacheHeaders;
119+
112120
/**
113121
* @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112
114122
*

src/Bridge/Symfony/Bundle/Resources/config/http_cache.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
<argument>%api_platform.http_cache.shared_max_age%</argument>
1212
<argument>%api_platform.http_cache.vary%</argument>
1313
<argument>%api_platform.http_cache.public%</argument>
14+
<argument type="service" id="api_platform.metadata.resource.metadata_factory" />
1415

1516
<tag name="kernel.event_listener" event="kernel.response" method="onKernelResponse" priority="-1" />
1617
</service>

src/HttpCache/EventListener/AddHeadersListener.php

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
namespace ApiPlatform\Core\HttpCache\EventListener;
1515

16+
use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface;
1617
use ApiPlatform\Core\Util\RequestAttributesExtractor;
1718
use Symfony\Component\HttpKernel\Event\FilterResponseEvent;
1819

@@ -30,14 +31,16 @@ final class AddHeadersListener
3031
private $sharedMaxAge;
3132
private $vary;
3233
private $public;
34+
private $resourceMetadataFactory;
3335

34-
public function __construct(bool $etag = false, int $maxAge = null, int $sharedMaxAge = null, array $vary = null, bool $public = null)
36+
public function __construct(bool $etag = false, int $maxAge = null, int $sharedMaxAge = null, array $vary = null, bool $public = null, ResourceMetadataFactoryInterface $resourceMetadataFactory = null)
3537
{
3638
$this->etag = $etag;
3739
$this->maxAge = $maxAge;
3840
$this->sharedMaxAge = $sharedMaxAge;
3941
$this->vary = $vary;
4042
$this->public = $public;
43+
$this->resourceMetadataFactory = $resourceMetadataFactory;
4144
}
4245

4346
public function onKernelResponse(FilterResponseEvent $event)
@@ -53,23 +56,29 @@ public function onKernelResponse(FilterResponseEvent $event)
5356
return;
5457
}
5558

56-
if ($this->etag) {
59+
$resourceCacheHeaders = [];
60+
if ($this->resourceMetadataFactory && $attributes = RequestAttributesExtractor::extractAttributes($request)) {
61+
$resourceMetadata = $this->resourceMetadataFactory->create($attributes['resource_class']);
62+
$resourceCacheHeaders = $resourceMetadata->getOperationAttribute($attributes, 'cache_headers', [], true);
63+
}
64+
65+
if ($this->etag && !$response->getEtag()) {
5766
$response->setEtag(md5($response->getContent()));
5867
}
5968

60-
if (null !== $this->maxAge) {
61-
$response->setMaxAge($this->maxAge);
69+
if (null !== ($maxAge = $resourceCacheHeaders['max_age'] ?? $this->maxAge) && !$response->headers->hasCacheControlDirective('max-age')) {
70+
$response->setMaxAge($maxAge);
6271
}
6372

6473
if (null !== $this->vary) {
6574
$response->setVary(array_diff($this->vary, $response->getVary()), false);
6675
}
6776

68-
if (null !== $this->sharedMaxAge) {
69-
$response->setSharedMaxAge($this->sharedMaxAge);
77+
if (null !== ($sharedMaxAge = $resourceCacheHeaders['shared_max_age'] ?? $this->sharedMaxAge) && !$response->headers->hasCacheControlDirective('s-maxage')) {
78+
$response->setSharedMaxAge($sharedMaxAge);
7079
}
7180

72-
if (null !== $this->public) {
81+
if (null !== $this->public && !$response->headers->hasCacheControlDirective('public')) {
7382
$this->public ? $response->setPublic() : $response->setPrivate();
7483
}
7584
}

tests/Annotation/ApiResourceTest.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ public function testConstruct()
2929
$resource = new ApiResource([
3030
'accessControl' => 'has_role("ROLE_FOO")',
3131
'accessControlMessage' => 'You are not foo.',
32-
'attributes' => ['foo' => 'bar', 'validation_groups' => ['baz', 'qux']],
32+
'attributes' => ['foo' => 'bar', 'validation_groups' => ['baz', 'qux'], 'cache_headers' => ['max_age' => 0, 'shared_max_age' => 0]],
3333
'collectionOperations' => ['bar' => ['foo']],
3434
'denormalizationContext' => ['groups' => ['foo']],
3535
'description' => 'description',
@@ -85,6 +85,7 @@ public function testConstruct()
8585
'pagination_partial' => true,
8686
'route_prefix' => '/foo',
8787
'validation_groups' => ['baz', 'qux'],
88+
'cache_headers' => ['max_age' => 0, 'shared_max_age' => 0],
8889
'sunset' => 'Thu, 11 Oct 2018 00:00:00 +0200',
8990
], $resource->attributes);
9091
}
@@ -107,6 +108,7 @@ public function testApiResourceAnnotation()
107108
'route_prefix' => '/whatever',
108109
'access_control' => "has_role('ROLE_FOO')",
109110
'access_control_message' => 'You are not foo.',
111+
'cache_headers' => ['max_age' => 0, 'shared_max_age' => 0],
110112
], $resource->attributes);
111113
}
112114

tests/Fixtures/AnnotatedClass.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
* itemOperations={"foo"={"bar"}},
2424
* collectionOperations={"bar"={"foo"}},
2525
* graphql={"query"={"normalization_context"={"groups"={"foo", "bar"}}}},
26-
* attributes={"foo"="bar", "route_prefix"="/whatever"},
26+
* attributes={"foo"="bar", "route_prefix"="/whatever", "cache_headers"={"max_age"=0, "shared_max_age"=0}},
2727
* routePrefix="/foo",
2828
* accessControl="has_role('ROLE_FOO')",
2929
* accessControlMessage="You are not foo."

tests/HttpCache/EventListener/AddHeadersListenerTest.php

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
namespace ApiPlatform\Core\Tests\HttpCache\EventListener;
1515

1616
use ApiPlatform\Core\HttpCache\EventListener\AddHeadersListener;
17+
use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface;
18+
use ApiPlatform\Core\Metadata\Resource\ResourceMetadata;
1719
use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy;
1820
use PHPUnit\Framework\TestCase;
1921
use Symfony\Component\HttpFoundation\Request;
@@ -81,11 +83,56 @@ public function testAddHeaders()
8183
$event->getRequest()->willReturn($request)->shouldBeCalled();
8284
$event->getResponse()->willReturn($response)->shouldBeCalled();
8385

84-
$listener = new AddHeadersListener(true, 100, 200, ['Accept', 'Accept-Encoding'], true);
86+
$factory = $this->prophesize(ResourceMetadataFactoryInterface::class);
87+
$factory->create(Dummy::class)->willReturn(new ResourceMetadata())->shouldBeCalled();
88+
89+
$listener = new AddHeadersListener(true, 100, 200, ['Accept', 'Accept-Encoding'], true, $factory->reveal());
8590
$listener->onKernelResponse($event->reveal());
8691

8792
$this->assertSame('"9893532233caff98cd083a116b013c0b"', $response->getEtag());
8893
$this->assertSame('max-age=100, public, s-maxage=200', $response->headers->get('Cache-Control'));
8994
$this->assertSame(['Accept', 'Cookie', 'Accept-Encoding'], $response->getVary());
9095
}
96+
97+
public function testDoNotSetHeaderWhenAlreadySet()
98+
{
99+
$request = new Request([], [], ['_api_resource_class' => Dummy::class, '_api_item_operation_name' => 'get']);
100+
$response = new Response('some content', 200, ['Vary' => ['Accept', 'Cookie']]);
101+
$response->setEtag('etag');
102+
$response->setMaxAge(300);
103+
// This also calls setPublic
104+
$response->setSharedMaxAge(400);
105+
106+
$event = $this->prophesize(FilterResponseEvent::class);
107+
$event->getRequest()->willReturn($request)->shouldBeCalled();
108+
$event->getResponse()->willReturn($response)->shouldBeCalled();
109+
110+
$factory = $this->prophesize(ResourceMetadataFactoryInterface::class);
111+
$factory->create(Dummy::class)->willReturn(new ResourceMetadata())->shouldBeCalled();
112+
113+
$listener = new AddHeadersListener(true, 100, 200, [], true, $factory->reveal());
114+
$listener->onKernelResponse($event->reveal());
115+
116+
$this->assertSame('"etag"', $response->getEtag());
117+
$this->assertSame('max-age=300, public, s-maxage=400', $response->headers->get('Cache-Control'));
118+
}
119+
120+
public function testSetHeadersFromResourceMetadata()
121+
{
122+
$request = new Request([], [], ['_api_resource_class' => Dummy::class, '_api_item_operation_name' => 'get']);
123+
$response = new Response('some content', 200, ['Vary' => ['Accept', 'Cookie']]);
124+
125+
$event = $this->prophesize(FilterResponseEvent::class);
126+
$event->getRequest()->willReturn($request)->shouldBeCalled();
127+
$event->getResponse()->willReturn($response)->shouldBeCalled();
128+
129+
$metadata = new ResourceMetadata(null, null, null, null, null, ['cache_headers' => ['max_age' => 123, 'shared_max_age' => 456]]);
130+
$factory = $this->prophesize(ResourceMetadataFactoryInterface::class);
131+
$factory->create(Dummy::class)->willReturn($metadata)->shouldBeCalled();
132+
133+
$listener = new AddHeadersListener(true, 100, 200, ['Accept', 'Accept-Encoding'], true, $factory->reveal());
134+
$listener->onKernelResponse($event->reveal());
135+
136+
$this->assertSame('max-age=123, public, s-maxage=456', $response->headers->get('Cache-Control'));
137+
}
91138
}

0 commit comments

Comments
 (0)