diff --git a/src/State/Processor/CacheableDocumentationProcessor.php b/src/State/Processor/CacheableDocumentationProcessor.php new file mode 100644 index 0000000000..6496b426e0 --- /dev/null +++ b/src/State/Processor/CacheableDocumentationProcessor.php @@ -0,0 +1,72 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\State\Processor; + +use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\ProcessorInterface; +use ApiPlatform\State\StopwatchAwareInterface; +use ApiPlatform\State\StopwatchAwareTrait; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; + +/** + * Adds an ETag and revalidating Cache-Control headers on the API documentation + * and entrypoint responses so clients can avoid re-downloading the (often large) + * payload when nothing changed. + * + * @template T1 + * @template T2 + * + * @implements ProcessorInterface + */ +final class CacheableDocumentationProcessor implements ProcessorInterface, StopwatchAwareInterface +{ + use StopwatchAwareTrait; + + /** + * @param ProcessorInterface $decorated + */ + public function __construct(private readonly ProcessorInterface $decorated) + { + } + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed + { + $response = $this->decorated->process($data, $operation, $uriVariables, $context); + + if (!$response instanceof Response || 200 !== $response->getStatusCode()) { + return $response; + } + + $content = $response->getContent(); + if (false === $content || '' === $content) { + return $response; + } + + $this->stopwatch?->start('api_platform.processor.cacheable_documentation'); + + $response->setEtag(md5($content)); + $response->setPublic(); + $response->setMaxAge(0); + $response->headers->addCacheControlDirective('must-revalidate'); + + if (($request = $context['request'] ?? null) instanceof Request) { + $response->isNotModified($request); + } + + $this->stopwatch?->stop('api_platform.processor.cacheable_documentation'); + + return $response; + } +} diff --git a/src/State/Tests/Processor/CacheableDocumentationProcessorTest.php b/src/State/Tests/Processor/CacheableDocumentationProcessorTest.php new file mode 100644 index 0000000000..64c28e068d --- /dev/null +++ b/src/State/Tests/Processor/CacheableDocumentationProcessorTest.php @@ -0,0 +1,105 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\State\Tests\Processor; + +use ApiPlatform\Metadata\Get; +use ApiPlatform\State\Processor\CacheableDocumentationProcessor; +use ApiPlatform\State\ProcessorInterface; +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; + +class CacheableDocumentationProcessorTest extends TestCase +{ + public function testItSetsEtagAndCacheHeadersOnResponse(): void + { + $body = '{"hello":"world"}'; + $processor = new CacheableDocumentationProcessor($this->decoratedReturning(new Response($body))); + + $response = $processor->process(new \stdClass(), new Get(), [], ['request' => new Request()]); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame('"'.md5($body).'"', $response->getEtag()); + $this->assertTrue($response->headers->hasCacheControlDirective('public')); + $this->assertTrue($response->headers->hasCacheControlDirective('must-revalidate')); + $this->assertSame(0, (int) $response->headers->getCacheControlDirective('max-age')); + $this->assertSame(200, $response->getStatusCode()); + } + + public function testItReturnsNotModifiedWhenIfNoneMatchHeaderMatches(): void + { + $body = '{"hello":"world"}'; + $etag = '"'.md5($body).'"'; + $request = new Request(); + $request->headers->set('If-None-Match', $etag); + $processor = new CacheableDocumentationProcessor($this->decoratedReturning(new Response($body))); + + $response = $processor->process(new \stdClass(), new Get(), [], ['request' => $request]); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame(304, $response->getStatusCode()); + $this->assertEmpty($response->getContent()); + $this->assertSame($etag, $response->getEtag()); + } + + public function testItPassesThroughWhenDecoratedDoesNotReturnResponse(): void + { + $data = new \stdClass(); + $processor = new CacheableDocumentationProcessor($this->decoratedReturning($data)); + + $this->assertSame($data, $processor->process($data, new Get(), [], ['request' => new Request()])); + } + + public function testItDoesNothingForNonOkResponses(): void + { + $response = new Response('error', 500); + $processor = new CacheableDocumentationProcessor($this->decoratedReturning($response)); + + $result = $processor->process(new \stdClass(), new Get(), [], ['request' => new Request()]); + + $this->assertSame($response, $result); + $this->assertNull($result->getEtag()); + } + + public function testItDoesNothingWhenResponseHasNoBody(): void + { + $response = new Response(''); + $processor = new CacheableDocumentationProcessor($this->decoratedReturning($response)); + + $result = $processor->process(new \stdClass(), new Get(), [], ['request' => new Request()]); + + $this->assertSame($response, $result); + $this->assertNull($result->getEtag()); + } + + public function testItStillSetsHeadersWhenRequestIsAbsent(): void + { + $body = 'payload'; + $processor = new CacheableDocumentationProcessor($this->decoratedReturning(new Response($body))); + + $response = $processor->process(new \stdClass(), new Get(), [], []); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame('"'.md5($body).'"', $response->getEtag()); + $this->assertSame(200, $response->getStatusCode()); + } + + private function decoratedReturning(mixed $value): ProcessorInterface + { + $decorated = $this->createStub(ProcessorInterface::class); + $decorated->method('process')->willReturn($value); + + return $decorated; + } +} diff --git a/src/Symfony/Bundle/Resources/config/symfony/events.php b/src/Symfony/Bundle/Resources/config/symfony/events.php index c8c2c833e7..2a464991af 100644 --- a/src/Symfony/Bundle/Resources/config/symfony/events.php +++ b/src/Symfony/Bundle/Resources/config/symfony/events.php @@ -14,6 +14,7 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; use ApiPlatform\State\Processor\AddLinkHeaderProcessor; +use ApiPlatform\State\Processor\CacheableDocumentationProcessor; use ApiPlatform\State\Processor\RespondProcessor; use ApiPlatform\State\Processor\SerializeProcessor; use ApiPlatform\State\Processor\WriteProcessor; @@ -145,6 +146,10 @@ $services->alias('api_platform.state_processor.documentation', 'api_platform.state_processor.respond'); + $services->set('api_platform.state_processor.documentation.cache', CacheableDocumentationProcessor::class) + ->decorate('api_platform.state_processor.documentation', null, 300) + ->args([service('api_platform.state_processor.documentation.cache.inner')]); + $services->set('api_platform.state_processor.documentation.serialize', SerializeProcessor::class) ->decorate('api_platform.state_processor.documentation', null, 200) ->args([