├── README.md ├── PurgerInterface.php ├── SouinPurger.php ├── VarnishXKeyPurger.php ├── LICENSE ├── composer.json ├── SurrogateKeysPurger.php ├── State ├── AddTagsProcessor.php └── AddHeadersProcessor.php └── VarnishPurger.php /README.md: -------------------------------------------------------------------------------- 1 | # API Platform - HTTP Cache 2 | 3 | The [HTTP Cache](https://httpwg.org/specs/rfc7234.html) component of the [API Platform](https://api-platform.com) framework. 4 | 5 | This component also provides integrations with [Varnish](https://varnish-cache.org/), [Souin](https://souin.io/) 6 | and other HTTP cache servers and services. 7 | 8 | [Documentation](https://api-platform.com/docs/core/performance/) 9 | 10 | > [!CAUTION] 11 | > 12 | > This is a read-only sub split of `api-platform/core`, please 13 | > [report issues](https://github.com/api-platform/core/issues) and 14 | > [send Pull Requests](https://github.com/api-platform/core/pulls) 15 | > in the [core API Platform repository](https://github.com/api-platform/core). 16 | -------------------------------------------------------------------------------- /PurgerInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\HttpCache; 15 | 16 | /** 17 | * Purges resources from the cache. 18 | * 19 | * @author Kévin Dunglas 20 | */ 21 | interface PurgerInterface 22 | { 23 | /** 24 | * Purges all responses containing the given resources from the cache. 25 | * 26 | * @param string[] $iris 27 | */ 28 | public function purge(array $iris): void; 29 | 30 | /** 31 | * Get the response header containing purged tags. 32 | * 33 | * @param string[] $iris 34 | */ 35 | public function getResponseHeaders(array $iris): array; 36 | } 37 | -------------------------------------------------------------------------------- /SouinPurger.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\HttpCache; 15 | 16 | use Symfony\Contracts\HttpClient\HttpClientInterface; 17 | 18 | /** 19 | * Purges Souin. 20 | * 21 | * @author Sylvain Combraque 22 | */ 23 | class SouinPurger extends SurrogateKeysPurger 24 | { 25 | private const MAX_HEADER_SIZE_PER_BATCH = 1500; 26 | private const SEPARATOR = ', '; 27 | private const HEADER = 'Surrogate-Key'; 28 | 29 | /** 30 | * @param HttpClientInterface[] $clients 31 | */ 32 | public function __construct(iterable $clients, int $maxHeaderLength = self::MAX_HEADER_SIZE_PER_BATCH) 33 | { 34 | parent::__construct($clients, $maxHeaderLength, self::HEADER, self::SEPARATOR); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /VarnishXKeyPurger.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\HttpCache; 15 | 16 | use Symfony\Contracts\HttpClient\HttpClientInterface; 17 | 18 | /** 19 | * Purges Varnish XKey. 20 | * 21 | * @author Kévin Dunglas 22 | */ 23 | final class VarnishXKeyPurger extends SurrogateKeysPurger 24 | { 25 | private const VARNISH_MAX_HEADER_LENGTH = 8000; 26 | private const VARNISH_SEPARATOR = ' '; 27 | 28 | /** 29 | * @param HttpClientInterface[] $clients 30 | */ 31 | public function __construct(iterable $clients, int $maxHeaderLength = self::VARNISH_MAX_HEADER_LENGTH, string $xkeyGlue = self::VARNISH_SEPARATOR) 32 | { 33 | parent::__construct($clients, $maxHeaderLength, 'xkey', $xkeyGlue); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT license 2 | 3 | Copyright (c) 2015-present Kévin Dunglas 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is furnished 10 | to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "api-platform/http-cache", 3 | "description": "API Platform HttpCache component", 4 | "type": "library", 5 | "keywords": [ 6 | "REST", 7 | "API", 8 | "cache", 9 | "HTTP" 10 | ], 11 | "homepage": "https://api-platform.com", 12 | "license": "MIT", 13 | "authors": [ 14 | { 15 | "name": "Kévin Dunglas", 16 | "email": "kevin@dunglas.fr", 17 | "homepage": "https://dunglas.fr" 18 | }, 19 | { 20 | "name": "API Platform Community", 21 | "homepage": "https://api-platform.com/comunnity/contributors" 22 | } 23 | ], 24 | "require": { 25 | "php": ">=8.2", 26 | "api-platform/metadata": "^4.2", 27 | "api-platform/state": "^4.2.4", 28 | "symfony/http-foundation": "^6.4.14 || ^7.0 || ^8.0" 29 | }, 30 | "require-dev": { 31 | "guzzlehttp/guzzle": "^6.0 || ^7.0 || ^8.0", 32 | "symfony/dependency-injection": "^6.4 || ^7.0 || ^8.0", 33 | "phpspec/prophecy-phpunit": "^2.2", 34 | "symfony/http-client": "^6.4 || ^7.0 || ^8.0", 35 | "symfony/type-info": "^7.3 || ^8.0", 36 | "phpunit/phpunit": "11.5.x-dev" 37 | }, 38 | "autoload": { 39 | "psr-4": { 40 | "ApiPlatform\\HttpCache\\": "" 41 | }, 42 | "exclude-from-classmap": [ 43 | "/Tests/" 44 | ] 45 | }, 46 | "config": { 47 | "preferred-install": { 48 | "*": "dist" 49 | }, 50 | "sort-packages": true, 51 | "allow-plugins": { 52 | "composer/package-versions-deprecated": true, 53 | "phpstan/extension-installer": true 54 | } 55 | }, 56 | "extra": { 57 | "branch-alias": { 58 | "dev-main": "4.3.x-dev", 59 | "dev-4.2": "4.2.x-dev", 60 | "dev-3.4": "3.4.x-dev", 61 | "dev-4.1": "4.1.x-dev" 62 | }, 63 | "symfony": { 64 | "require": "^6.4 || ^7.0 || ^8.0" 65 | }, 66 | "thanks": { 67 | "name": "api-platform/api-platform", 68 | "url": "https://github.com/api-platform/api-platform" 69 | } 70 | }, 71 | "scripts": { 72 | "test": "./vendor/bin/phpunit" 73 | }, 74 | "repositories": [ 75 | { 76 | "type": "vcs", 77 | "url": "https://github.com/soyuka/phpunit" 78 | } 79 | ] 80 | } 81 | -------------------------------------------------------------------------------- /SurrogateKeysPurger.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\HttpCache; 15 | 16 | use ApiPlatform\Metadata\Exception\RuntimeException; 17 | use Symfony\Component\HttpFoundation\Request; 18 | use Symfony\Contracts\HttpClient\HttpClientInterface; 19 | 20 | /** 21 | * Surrogate keys purger. 22 | * 23 | * @author Sylvain Combraque 24 | */ 25 | class SurrogateKeysPurger implements PurgerInterface 26 | { 27 | private const MAX_HEADER_SIZE_PER_BATCH = 1500; 28 | private const SEPARATOR = ', '; 29 | private const HEADER = 'Surrogate-Key'; 30 | 31 | /** 32 | * @param HttpClientInterface[] $clients 33 | */ 34 | public function __construct(protected readonly iterable $clients, protected readonly int $maxHeaderLength = self::MAX_HEADER_SIZE_PER_BATCH, protected readonly string $header = self::HEADER, protected readonly string $separator = self::SEPARATOR) 35 | { 36 | } 37 | 38 | /** 39 | * @return \Iterator 40 | */ 41 | private function getChunkedIris(array $iris): \Iterator 42 | { 43 | if (!$iris) { 44 | return; 45 | } 46 | 47 | $chunk = array_shift($iris); 48 | foreach ($iris as $iri) { 49 | $nextChunk = \sprintf('%s%s%s', $chunk, $this->separator, $iri); 50 | if (\strlen($nextChunk) <= $this->maxHeaderLength) { 51 | $chunk = $nextChunk; 52 | continue; 53 | } 54 | 55 | yield $chunk; 56 | $chunk = $iri; 57 | } 58 | 59 | yield $chunk; 60 | } 61 | 62 | /** 63 | * {@inheritdoc} 64 | */ 65 | public function purge(array $iris): void 66 | { 67 | foreach ($this->getChunkedIris($iris) as $chunk) { 68 | if (\strlen((string) $chunk) > $this->maxHeaderLength) { 69 | throw new RuntimeException(\sprintf('IRI "%s" is too long to fit current max header length (currently set to "%s"). You can increase it using the "api_platform.http_cache.invalidation.max_header_length" parameter.', $chunk, $this->maxHeaderLength)); 70 | } 71 | 72 | foreach ($this->clients as $client) { 73 | $client->request( 74 | Request::METHOD_PURGE, 75 | '', 76 | ['headers' => [$this->header => $chunk]] 77 | ); 78 | } 79 | } 80 | } 81 | 82 | /** 83 | * {@inheritdoc} 84 | */ 85 | public function getResponseHeaders(array $iris): array 86 | { 87 | return [$this->header => implode($this->separator, $iris)]; 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /State/AddTagsProcessor.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\HttpCache\State; 15 | 16 | use ApiPlatform\HttpCache\PurgerInterface; 17 | use ApiPlatform\Metadata\CollectionOperationInterface; 18 | use ApiPlatform\Metadata\HttpOperation; 19 | use ApiPlatform\Metadata\IriConverterInterface; 20 | use ApiPlatform\Metadata\Operation; 21 | use ApiPlatform\Metadata\UrlGeneratorInterface; 22 | use ApiPlatform\State\ProcessorInterface; 23 | use ApiPlatform\State\UriVariablesResolverTrait; 24 | use Symfony\Component\HttpFoundation\Response; 25 | 26 | /** 27 | * Sets the list of resources' IRIs included in this response in the configured cache tag HTTP header and/or "xkey" HTTP headers. 28 | * 29 | * By default the "Cache-Tags" HTTP header is used because it is supported by CloudFlare. 30 | * 31 | * @see https://developers.cloudflare.com/cache/how-to/purge-cache#add-cache-tag-http-response-headers 32 | * 33 | * The "xkey" is used because it is supported by Varnish. 34 | * @see https://docs.varnish-software.com/varnish-cache-plus/vmods/ykey/ 35 | * 36 | * @author Kévin Dunglas 37 | */ 38 | final class AddTagsProcessor implements ProcessorInterface 39 | { 40 | use UriVariablesResolverTrait; 41 | 42 | public function __construct(private readonly ProcessorInterface $decorated, private readonly IriConverterInterface $iriConverter, private readonly ?PurgerInterface $purger = null) 43 | { 44 | } 45 | 46 | /** 47 | * Adds the configured HTTP cache tag and "xkey" headers. 48 | */ 49 | public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed 50 | { 51 | $response = $this->decorated->process($data, $operation, $uriVariables, $context); 52 | 53 | if ( 54 | !($request = $context['request'] ?? null) 55 | || !$request->isMethodCacheable() 56 | || !$response instanceof Response 57 | || !$operation instanceof HttpOperation 58 | || !$response->isCacheable() 59 | ) { 60 | return $response; 61 | } 62 | 63 | $resources = $request->attributes->get('_resources', []); 64 | if ($operation instanceof CollectionOperationInterface) { 65 | // Allows to purge collections 66 | $uriVariables = $this->getOperationUriVariables($operation, $request->attributes->all(), $operation->getClass()); 67 | $iri = $this->iriConverter->getIriFromResource($operation->getClass(), UrlGeneratorInterface::ABS_PATH, $operation, ['uri_variables' => $uriVariables]); 68 | 69 | $resources[$iri] = $iri; 70 | } 71 | 72 | if (!$resources) { 73 | return $response; 74 | } 75 | 76 | if (!$this->purger) { 77 | $response->headers->set('Cache-Tags', implode(',', $resources)); 78 | 79 | return $response; 80 | } 81 | 82 | $headers = $this->purger->getResponseHeaders($resources); 83 | 84 | foreach ($headers as $key => $value) { 85 | $response->headers->set($key, $value); 86 | } 87 | 88 | return $response; 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /VarnishPurger.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\HttpCache; 15 | 16 | use Symfony\Contracts\HttpClient\HttpClientInterface; 17 | 18 | /** 19 | * Purges Varnish. 20 | * 21 | * @author Kévin Dunglas 22 | */ 23 | final class VarnishPurger implements PurgerInterface 24 | { 25 | private const DEFAULT_VARNISH_MAX_HEADER_LENGTH = 8000; 26 | private const REGEXP_PATTERN = '(%s)($|\,)'; 27 | private readonly int $maxHeaderLength; 28 | 29 | /** 30 | * @param HttpClientInterface[] $clients 31 | */ 32 | public function __construct(private readonly iterable $clients, int $maxHeaderLength = self::DEFAULT_VARNISH_MAX_HEADER_LENGTH) 33 | { 34 | $this->maxHeaderLength = $maxHeaderLength - mb_strlen(self::REGEXP_PATTERN) + 2; // 2 for %s 35 | } 36 | 37 | /** 38 | * Calculate how many tags fit into the header. 39 | * 40 | * This assumes that the tags are separated by one character. 41 | * 42 | * From https://github.com/FriendsOfSymfony/FOSHttpCache/blob/2.8.0/src/ProxyClient/HttpProxyClient.php#L137 43 | * 44 | * @param string[] $escapedTags 45 | * @param string $glue The concatenation string to use 46 | * 47 | * @return int Number of tags per tag invalidation request 48 | */ 49 | private function determineTagsPerHeader(array $escapedTags, string $glue): int 50 | { 51 | if (mb_strlen(implode($glue, $escapedTags)) < $this->maxHeaderLength) { 52 | return \count($escapedTags); 53 | } 54 | /* 55 | * estimate the amount of tags to invalidate by dividing the max 56 | * header length by the largest tag (minus the glue length) 57 | */ 58 | $tagsize = max(array_map('mb_strlen', $escapedTags)); 59 | $gluesize = \strlen($glue); 60 | 61 | return (int) floor(($this->maxHeaderLength + $gluesize) / ($tagsize + $gluesize)) ?: 1; 62 | } 63 | 64 | /** 65 | * {@inheritdoc} 66 | */ 67 | public function purge(array $iris): void 68 | { 69 | if (!$iris) { 70 | return; 71 | } 72 | 73 | $chunkSize = $this->determineTagsPerHeader($iris, '|'); 74 | 75 | $irisChunks = array_chunk($iris, $chunkSize); 76 | foreach ($irisChunks as $irisChunk) { 77 | $this->purgeRequest($irisChunk); 78 | } 79 | } 80 | 81 | /** 82 | * {@inheritdoc} 83 | */ 84 | public function getResponseHeaders(array $iris): array 85 | { 86 | return ['Cache-Tags' => implode(',', $iris)]; 87 | } 88 | 89 | private function purgeRequest(array $iris): void 90 | { 91 | // Create the regex to purge all tags in just one request 92 | $parts = array_map(static fn ($iri): string => // here we should remove the prefix as it's not discriminent and cost a lot to compute 93 | preg_quote($iri), $iris); 94 | 95 | foreach ($this->chunkRegexParts($parts) as $regex) { 96 | $regex = \sprintf(self::REGEXP_PATTERN, $regex); 97 | $this->banRegex($regex); 98 | } 99 | } 100 | 101 | private function banRegex(string $regex): void 102 | { 103 | foreach ($this->clients as $client) { 104 | $client->request('BAN', '', ['headers' => ['ApiPlatform-Ban-Regex' => $regex]]); 105 | } 106 | } 107 | 108 | private function chunkRegexParts(array $parts): iterable 109 | { 110 | if (1 === \count($parts)) { 111 | yield $parts[0]; 112 | 113 | return; 114 | } 115 | 116 | $concatenatedParts = implode("\n", $parts); 117 | 118 | if (\strlen($concatenatedParts) <= $this->maxHeaderLength) { 119 | yield str_replace("\n", '|', $concatenatedParts); 120 | 121 | return; 122 | } 123 | 124 | $lastSeparator = strrpos(substr($concatenatedParts, 0, $this->maxHeaderLength + 1), "\n"); 125 | 126 | $chunk = substr($concatenatedParts, 0, $lastSeparator); 127 | 128 | yield str_replace("\n", '|', $chunk); 129 | 130 | $nextParts = \array_slice($parts, substr_count($chunk, "\n") + 1); 131 | 132 | yield from $this->chunkRegexParts($nextParts); 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /State/AddHeadersProcessor.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\HttpCache\State; 15 | 16 | use ApiPlatform\Metadata\HttpOperation; 17 | use ApiPlatform\Metadata\Operation; 18 | use ApiPlatform\State\ProcessorInterface; 19 | use Symfony\Component\HttpFoundation\Response; 20 | 21 | /** 22 | * @template T1 23 | * @template T2 24 | * 25 | * @implements ProcessorInterface 26 | */ 27 | final class AddHeadersProcessor implements ProcessorInterface 28 | { 29 | /** 30 | * @param ProcessorInterface $decorated 31 | */ 32 | public function __construct( 33 | private readonly ProcessorInterface $decorated, 34 | private readonly bool $etag = false, 35 | private readonly ?int $maxAge = null, 36 | private readonly ?int $sharedMaxAge = null, 37 | private readonly ?array $vary = null, 38 | private readonly ?bool $public = null, 39 | private readonly ?int $staleWhileRevalidate = null, 40 | private readonly ?int $staleIfError = null, 41 | ) { 42 | } 43 | 44 | public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed 45 | { 46 | $response = $this->decorated->process($data, $operation, $uriVariables, $context); 47 | 48 | if ( 49 | !($request = $context['request'] ?? null) 50 | || !$request->isMethodCacheable() 51 | || !$response instanceof Response 52 | || !$operation instanceof HttpOperation 53 | ) { 54 | return $response; 55 | } 56 | 57 | if (!($content = $response->getContent()) || !$response->isSuccessful()) { 58 | return $response; 59 | } 60 | 61 | $resourceCacheHeaders = $operation->getCacheHeaders() ?? []; 62 | 63 | $public = ($resourceCacheHeaders['public'] ?? $this->public); 64 | 65 | $options = [ 66 | 'etag' => $this->etag && !$response->getEtag() ? hash('xxh3', (string) $content) : null, 67 | 'max_age' => null !== ($maxAge = $resourceCacheHeaders['max_age'] ?? $this->maxAge) && !$response->headers->hasCacheControlDirective('max-age') ? $maxAge : null, 68 | // Cache-Control "s-maxage" is only relevant is resource is not marked as "private" 69 | 's_maxage' => false !== $public && null !== ($sharedMaxAge = $resourceCacheHeaders['shared_max_age'] ?? $this->sharedMaxAge) && !$response->headers->hasCacheControlDirective('s-maxage') ? $sharedMaxAge : null, 70 | 'public' => null !== $public && !$response->headers->hasCacheControlDirective('public') ? $public : null, 71 | 'stale_while_revalidate' => null !== ($staleWhileRevalidate = $resourceCacheHeaders['stale_while_revalidate'] ?? $this->staleWhileRevalidate) && !$response->headers->hasCacheControlDirective('stale-while-revalidate') ? $staleWhileRevalidate : null, 72 | 'stale_if_error' => null !== ($staleIfError = $resourceCacheHeaders['stale_if_error'] ?? $this->staleIfError) && !$response->headers->hasCacheControlDirective('stale-if-error') ? $staleIfError : null, 73 | 'must_revalidate' => null !== ($mustRevalidate = $resourceCacheHeaders['must_revalidate'] ?? null) && !$response->headers->hasCacheControlDirective('must-revalidate') ? $mustRevalidate : null, 74 | 'proxy_revalidate' => null !== ($proxyRevalidate = $resourceCacheHeaders['proxy_revalidate'] ?? null) && !$response->headers->hasCacheControlDirective('proxy-revalidate') ? $proxyRevalidate : null, 75 | 'no_cache' => null !== ($noCache = $resourceCacheHeaders['no_cache'] ?? null) && !$response->headers->hasCacheControlDirective('no-cache') ? $noCache : null, 76 | 'no_store' => null !== ($noStore = $resourceCacheHeaders['no_store'] ?? null) && !$response->headers->hasCacheControlDirective('no-store') ? $noStore : null, 77 | 'no_transform' => null !== ($noTransform = $resourceCacheHeaders['no_transform'] ?? null) && !$response->headers->hasCacheControlDirective('no-transform') ? $noTransform : null, 78 | 'immutable' => null !== ($immutable = $resourceCacheHeaders['immutable'] ?? null) && !$response->headers->hasCacheControlDirective('immutable') ? $immutable : null, 79 | ]; 80 | 81 | $response->setCache($options); 82 | 83 | $vary = $resourceCacheHeaders['vary'] ?? $this->vary; 84 | if (null !== $vary) { 85 | $response->setVary(array_diff($vary, $response->getVary()), false); 86 | } 87 | 88 | return $response; 89 | } 90 | } 91 | --------------------------------------------------------------------------------