Skip to content

Commit

Permalink
Add a PHP 8 attribute for configuration (#10)
Browse files Browse the repository at this point in the history
This adds a PHP 8 attribute for configuration, and deprecates using the old annotation class.
  • Loading branch information
mpdude authored Mar 28, 2024
1 parent 2d153e5 commit 3efba6e
Show file tree
Hide file tree
Showing 6 changed files with 191 additions and 180 deletions.
153 changes: 53 additions & 100 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,115 +1,73 @@
# WebfactoryHttpCacheBundle

WebfactoryHttpCacheBundle is a Symfony bundle that features a more
`WebfactoryHttpCacheBundle` is a Symfony bundle that features a more
powerful [HTTP cache validation via the last modified header] than the
```@Cache``` annotation in the excellent [SensioFrameworkExtraBundle].
`#[Cache]` attribute contained in the [symfony/http-kernel package].

[HTTP cache validation via the last modified header]: https://symfony.com/doc/current/http_cache/validation.html#validation-with-the-last-modified-header
[SensioFrameworkExtraBundle]: https://symfony.com/doc/current/bundles/SensioFrameworkExtraBundle/annotations/cache.html
[symfony/http-kernel package]: https://symfony.com/doc/current/http_cache.html#http-cache-expiration-intro

While the SensioFrameworkExtraBundle's ```@Cache``` annotation restricts
you to the request parameters, the ```@ReplaceWithNotModifiedResponse```
annotation lets you write small LastModifiedDeterminators for each one
of the underlying ressources of the requested page, They can be reused
and combined freely and can even be defined as services.
The `#[ReplaceWithNotModifiedResponse]` attribute lets you write small
`LastModifiedDeterminators` for each one of the underlying resources
of the requested page. They can be reused and combined freely and can
even be defined as services.

Lets take the example from the SensioFrameworkExtraBundle docs (stripped
off the ETag part, which is not supported by the
WebfactoryHttpCacheBundle):
Consider this controller code:

```php
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Cache;

/**
* @Cache(lastModified="post.getUpdatedAt()")
*/
public function indexAction(Post $post)
{
// your code
// won't be called in case of a 304
}
```

This falls short if the rendered template e.g. contains information
about the x latest posts. That can be done with the
```@ReplaceWithNotModifiedResponse``` annotation:
<?php

```php
use Webfactory\HttpCacheBundle\NotModified\Annotation\ReplaceWithNotModifiedResponse;
// ...
use Webfactory\HttpCacheBundle\NotModified\Attribute\ReplaceWithNotModifiedResponse;

/**
* @ReplaceWithNotModifiedResponse({"@app_caching_post", "@app_caching_latest_posts"})
*/
public function indexAction(Post $post)
{
// your code
// won't be called in case of a 304
class MyController {
// Routing etc. configuration skipped for brevity

#[ReplaceWithNotModifiedResponse(["@app_caching_post", "@app_caching_latest_posts"])]
public function indexAction(Post $post): Response
{
// your code
// won't be called in case of a 304
}
}
```

When Symfony's routing has chosen this controller action, all of the
LastModifiedDeterminators are called to return their respective last
`LastModifiedDeterminator`s are called to return their respective last
modified date.

In this case, both LastModifiedDeterminators are configured as services:
```@app_caching_post``` and ```@app_caching_latest_posts```. The first
one returns the update date of the requests $post, the second one may
`@app_caching_post` and `@app_caching_latest_posts`. The first
one returns the update date of the requests `$post`, the second one may
use the PostRepository injected from the DI container to return the last
update date of the x latest posts.

Then, ReplaceWithNotModifiedResponse combines all of the
LastModifiedDeterminators dates to determine to last modified date of
`#[ReplaceWithNotModifiedResponse]` combines all of the
`LastModifiedDeterminators` dates to determine to last modified date of
the overall page. Finally, if the request contains an appropriate
```if-not-modified-since``` header, the execution of the controller
action will be skipped and an empty response with a 304 Not Modified
status code will be sent. If your LastModifiedDeterminators are fast,
`if-not-modified-since` header, the execution of the controller
action will be skipped and an empty response with a "304 Not Modified"
status code will be sent. If your `LastModifiedDeterminators` are fast,
this can improve your performance greatly.

What we like about the LastModifiedDeterminators is that they encourage
What we like about the `LastModifiedDeterminators` is that they encourage
to separate the concerns nicely and encapsulate the tasks into small
units that are easy to understand, reusable and unit test.

*Note:* `@ReplaceWithNotModifiedResponse` does not alter or add
*Note:* `#[ReplaceWithNotModifiedResponse]` does not alter or add
`Cache-Control` header settings. So, by default your response will
remain `private` and end up in browser caches only. If you want it to be
kept in surrogate caches (like Varnish or the Symfony Http Cache), you
can add `@Cache(smaxage="0")`. This will make the response `public`, but
can add `#[Cache(smaxage: 0)]`. This will make the response `public`, but
also requires a revalidation on every request as the response is
*always* considered stale. [Learn more about Symonfy's HTTP caching].

[Learn more about Symonfy's HTTP caching]: http://symfony.com/doc/current/book/http_cache.html



## Installation

Install via [composer](https://getcomposer.org/):

composer require webfactory/http-cache-bundle

Register the bundle in your application:

```php
<?php
// app/AppKernel.php

public function registerBundles()
{
$bundles = array(
// ...
new Webfactory\HttpCacheBundle\WebfactoryHttpCacheBundle(),
// ...
);
// ...
}
```



## Usage

Choose a controller action you want to possibly replace with a 304 Not Modified response. Write one LastModifiedDeterminator for each
of the different underlying resources, implementing the ```Webfactory\HttpCacheBundle\NotModified\LastModifiedDeterminator``` interface.
of the different underlying resources, implementing the `Webfactory\HttpCacheBundle\NotModified\LastModifiedDeterminator` interface.

```php
<?php
Expand All @@ -125,28 +83,25 @@ use Webfactory\HttpCacheBundle\NotModified\LastModifiedDeterminator;
*/
final class PostsLastModifiedDeterminator implements LastModifiedDeterminator
{
/** @var EntityRepository */
private $postRepository;

public function __construct(PostRepository $postRepository)
{
$this->postRepository = $postRepository;
}
public function __construct(
private readonly BlogPostRepository $blogPostRepository,
) {

public function getLastModified(Request $request)
public function getLastModified(Request $request): ?\DateTime
{
$post = $this->postRepository->findLatest();
return $post->getPublishingDate();
$post = $this->blogPostRepository->findLatest();

return $post?->getPublishingDate();
}
}
```

You can use the ```$request``` in the getLastModified e.g. to get route parameters, which is necessary e.g. if you have
You can use the `$request` in the getLastModified e.g. to get route parameters, which is necessary e.g. if you have
some filters coded in the requested URL.

If your LastModifiedDeterminator has dependencies you'd like to be injected, configure it as a service.

Then, simply add the ```ReplaceWithNotModifiedResponse``` annotation to the chosen controller method and parameterise it
Then, add the `#[ReplaceWithNotModifiedResponse]` attribute to the chosen controller method and parameterize it
with your LastModifiedDeterminators:

```php
Expand All @@ -155,13 +110,11 @@ with your LastModifiedDeterminators:
namespace src\Controller;

use Symfony\Component\HttpFoundation\Response;
use Webfactory\HttpCacheBundle\NotModified\Annotation\ReplaceWithNotModifiedResponse;
use Webfactory\HttpCacheBundle\NotModified\Attribute\ReplaceWithNotModifiedResponse;

final class MyController
{
/**
* @ReplaceWithNotModifiedResponse({...})
*/
#[ReplaceWithNotModifiedResponse([...])]
public function indexAction()
{
// ...
Expand All @@ -172,36 +125,36 @@ final class MyController

The most simple form of adding a LastModifiedDeterminator is passing its fully qualfified class name:

@ReplaceWithNotModifiedResponse({"\App\Caching\MySimpleLastModifiedDeterminator"})
#[ReplaceWithNotModifiedResponse([\App\Caching\MySimpleLastModifiedDeterminator::class])]

If your LastModifiedDeterminator needs simple constructor arguments, you can pass them in array form:

@ReplaceWithNotModifiedResponse({ {"\App\Caching\MyLastModifiedDeterminator" = {"key1" = 1, "key2" = {"*"} } } })
#[ReplaceWithNotModifiedResponse([\App\Caching\MyLastModifiedDeterminator::class => ["key1" => 1, "key2" => ["*"]]])]

This would pass the array ['key1' => 1, 'key2' => ['*']] as an argument to MyLastModifiedDeterminator's constructor.

If your LastModifiedDeterminator has more sophisticated dependencies, you can define the LastModifiedDeterminator as a service, e.g.:

```yaml
`yaml
// services.yml
services:
app_caching_latest_posts:
class: App\Caching\PostsLastModifiedDeterminator
arguments:
- @repository_post
```
`

and note the service name to the Annotation:

@ReplaceWithNotModifiedResponse({"app_caching_latest_posts"})
#[ReplaceWithNotModifiedResponse(["@app_caching_latest_posts"])]

To combine multiple LastModifiedDeterminators, simply add all of them to the annotation:

@ReplaceWithNotModifiedResponse({
#[ReplaceWithNotModifiedResponse([
"@app_caching_latest_posts",
"\App\Caching\MySimpleLastModifiedDeterminator",
{"\App\Caching\MyLastModifiedDeterminator" = {"key1" = 1, "key2" = {"*"}}}
})
\App\Caching\MySimpleLastModifiedDeterminator::class,
[\App\Caching\MyLastModifiedDeterminator::class => ["key1" = 1, "key2" => ["*"]]
])]

The latest last modified date determines the last modified date of the response.

Expand All @@ -212,4 +165,4 @@ This bundle was started at webfactory GmbH, Bonn.
- <https://www.webfactory.de>
- <https://twitter.com/webfactory>

Copyright 2018-2019 webfactory GmbH, Bonn. Code released under [the MIT license](LICENSE).
Copyright 2018-2024 webfactory GmbH, Bonn. Code released under [the MIT license](LICENSE).
6 changes: 6 additions & 0 deletions UPGRADING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Upgrade notes for `WebfactoryHttpCacheBundle`

## Version 1.4.0

* The `\Webfactory\HttpCacheBundle\NotModified\Annotation\ReplaceWithNotModifiedResponse` annotation has been deprecated. Use the
`\Webfactory\HttpCacheBundle\NotModified\Attribute\ReplaceWithNotModifiedResponse` attribute for configuration instead.
5 changes: 3 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,12 @@

"require": {
"php": "^7.1|8.0.*|8.1.*",
"doctrine/annotations": "^1.0",
"symfony/config": "^4.4 | ^5.0 | ^6.0",
"symfony/dependency-injection": "^4.4 | ^5.0 | ^6.0",
"symfony/deprecation-contracts": "^2.0|^3.0",
"symfony/http-foundation": "^4.4 | ^5.0 | ^6.0",
"symfony/http-kernel": "^4.4 | ^5.0 | ^6.0",
"doctrine/annotations": "^1.0"
"symfony/http-kernel": "^4.4 | ^5.0 | ^6.0"
},

"require-dev": {
Expand Down
89 changes: 13 additions & 76 deletions src/NotModified/Annotation/ReplaceWithNotModifiedResponse.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,88 +9,25 @@

namespace Webfactory\HttpCacheBundle\NotModified\Annotation;

use DateTime;
use RuntimeException;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\Request;
use Webfactory\HttpCacheBundle\NotModified\LastModifiedDeterminator;
use Webfactory\HttpCacheBundle\NotModified\Attribute;

/**
* This Annotation determines the latest last modified date over all of its LastModifiedDeterminators. This date is used
* by the \Webfactory\HttpCacheBundle\NotModified\EventListener to possibly replace the execution of a controller with
* sending a Not Modified HTTP response.
*
* @Annotation
*
* @deprecated, to be replaced by attribute-based configuration
*/
final class ReplaceWithNotModifiedResponse
final class ReplaceWithNotModifiedResponse extends Attribute\ReplaceWithNotModifiedResponse
{
/** @var array */
private $parameters;

/** @var LastModifiedDeterminator[] */
private $lastModifiedDeterminators;

/** @var ContainerInterface */
private $container;

/** @var DateTime|null */
private $lastModified;

public function __construct(array $parameters)
{
$this->parameters = $parameters;
}

/**
* @return DateTime|null
*/
public function determineLastModified(Request $request)
{
$this->initialiseLastModifiedDeterminators();

foreach ($this->lastModifiedDeterminators as $lastModifiedDeterminator) {
$lastModifiedOfCurrentDeterminator = $lastModifiedDeterminator->getLastModified($request);
if (null === $this->lastModified || $this->lastModified < $lastModifiedOfCurrentDeterminator) {
$this->lastModified = $lastModifiedOfCurrentDeterminator;
}
}

return $this->lastModified;
}

public function setContainer(ContainerInterface $container)
{
$this->container = $container;
}

private function initialiseLastModifiedDeterminators()
{
if (0 === count($this->parameters['value'])) {
throw new RuntimeException('The annotation '.get_class($this).' has to be parametrised with LastModifiedDeterminators.');
}

foreach ($this->parameters['value'] as $lastModifiedDeterminatorDescription) {
$lastModifiedDeterminator = null;

if (is_string($lastModifiedDeterminatorDescription)) {
if ('@' === $lastModifiedDeterminatorDescription[0]) {
$lastModifiedDeterminator = $this->container->get(substr($lastModifiedDeterminatorDescription, 1));
} else {
$lastModifiedDeterminator = new $lastModifiedDeterminatorDescription();
}
}

if (is_array($lastModifiedDeterminatorDescription)) {
$lastModifiedDeterminatorClass = key($lastModifiedDeterminatorDescription);
$lastModifiedDeterminatorParameter = current($lastModifiedDeterminatorDescription);
$lastModifiedDeterminator = new $lastModifiedDeterminatorClass($lastModifiedDeterminatorParameter);
}

if (!($lastModifiedDeterminator instanceof LastModifiedDeterminator)) {
throw new RuntimeException('The class "'.get_class($lastModifiedDeterminator).'" does not implement '.LastModifiedDeterminator::class.'.');
}

$this->lastModifiedDeterminators[] = $lastModifiedDeterminator;
}
trigger_deprecation(
'webfactory/http-cache-bundle',
'1.4.0',
'The %s annotation has been deprecated, use the %s attribute instead.',
__CLASS__,
Attribute\ReplaceWithNotModifiedResponse::class
);

parent::__construct($parameters['value']);
}
}
Loading

0 comments on commit 3efba6e

Please sign in to comment.