From 469d86a7a4ccc52ba0e121e4be6dfaa9f483a8e1 Mon Sep 17 00:00:00 2001 From: Abderrahim GHAZALI Date: Sun, 26 Apr 2026 02:04:13 +0200 Subject: [PATCH 1/5] feat(state): cache hydra documentation and entrypoint responses Adds an ETag and revalidating Cache-Control headers to the api_doc and api_entrypoint routes so clients (Admin UI, generated clients, schema parsers) can avoid re-downloading the full documentation when nothing changed. Implemented as a state processor decorating api_platform.state_processor.documentation, mirroring the existing AddLinkHeaderProcessor pattern. The processor computes a strong ETag from the response body and delegates 304 handling to Response::isNotModified(), which respects the client's If-None-Match header. Refs #4074 --- .../CacheableDocumentationProcessor.php | 72 ++++++++++++ .../CacheableDocumentationProcessorTest.php | 105 ++++++++++++++++++ .../Resources/config/symfony/events.php | 5 + 3 files changed, 182 insertions(+) create mode 100644 src/State/Processor/CacheableDocumentationProcessor.php create mode 100644 src/State/Tests/Processor/CacheableDocumentationProcessorTest.php 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([ From c1cb99c533c6d56e8bc97bb8c0d3dc0cc84d7290 Mon Sep 17 00:00:00 2001 From: soyuka Date: Tue, 28 Apr 2026 13:12:57 +0200 Subject: [PATCH 2/5] test: add functional test in non-listener mode --- .../Resources/config/symfony/controller.php | 11 +- .../CacheableDocumentationDummy.php | 39 +++++ .../State/CacheableDocumentationTest.php | 146 ++++++++++++++++++ 3 files changed, 194 insertions(+), 2 deletions(-) create mode 100644 tests/Fixtures/TestBundle/ApiResource/CacheableDocumentationDummy.php create mode 100644 tests/Functional/State/CacheableDocumentationTest.php diff --git a/src/Symfony/Bundle/Resources/config/symfony/controller.php b/src/Symfony/Bundle/Resources/config/symfony/controller.php index 2ac24e613e..d5a20ad7bd 100644 --- a/src/Symfony/Bundle/Resources/config/symfony/controller.php +++ b/src/Symfony/Bundle/Resources/config/symfony/controller.php @@ -13,6 +13,7 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; +use ApiPlatform\State\Processor\CacheableDocumentationProcessor; use ApiPlatform\Symfony\Action\DocumentationAction; use ApiPlatform\Symfony\Action\EntrypointAction; use ApiPlatform\Symfony\Controller\MainController; @@ -30,12 +31,18 @@ service('logger')->ignoreOnInvalid(), ]); + $services->alias('api_platform.state_processor.documentation', 'api_platform.state_processor.main'); + + $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.action.entrypoint', EntrypointAction::class) ->public() ->args([ service('api_platform.metadata.resource.name_collection_factory'), service('api_platform.state_provider.main'), - service('api_platform.state_processor.main'), + service('api_platform.state_processor.documentation'), '%api_platform.docs_formats%', ]); @@ -48,7 +55,7 @@ '%api_platform.version%', service('api_platform.openapi.factory')->nullOnInvalid(), service('api_platform.state_provider.main'), - service('api_platform.state_processor.main'), + service('api_platform.state_processor.documentation'), service('api_platform.negotiator')->nullOnInvalid(), '%api_platform.docs_formats%', '%api_platform.enable_swagger_ui%', diff --git a/tests/Fixtures/TestBundle/ApiResource/CacheableDocumentationDummy.php b/tests/Fixtures/TestBundle/ApiResource/CacheableDocumentationDummy.php new file mode 100644 index 0000000000..66c158583b --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/CacheableDocumentationDummy.php @@ -0,0 +1,39 @@ + + * + * 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\Tests\Fixtures\TestBundle\ApiResource; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; + +#[ApiResource( + shortName: 'CacheableDocumentationDummy', + operations: [ + new GetCollection( + uriTemplate: '/cacheable_documentation_dummies', + provider: [self::class, 'provide'], + ), + ], +)] +class CacheableDocumentationDummy +{ + public function __construct(#[ApiProperty(identifier: true)] public int $id) + { + } + + public static function provide(): iterable + { + return [new self(1), new self(2)]; + } +} diff --git a/tests/Functional/State/CacheableDocumentationTest.php b/tests/Functional/State/CacheableDocumentationTest.php new file mode 100644 index 0000000000..81de8dd58b --- /dev/null +++ b/tests/Functional/State/CacheableDocumentationTest.php @@ -0,0 +1,146 @@ + + * + * 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\Tests\Functional\State; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\CacheableDocumentationDummy; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use PHPUnit\Framework\Attributes\DataProvider; +use Symfony\Component\Config\Loader\LoaderInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; + +class CacheableDocumentationAppKernel extends \AppKernel +{ + public static bool $useSymfonyListeners = false; + + public function getCacheDir(): string + { + return parent::getCacheDir().'/cache_doc_'.($this::$useSymfonyListeners ? 'listeners' : 'controller'); + } + + public function getLogDir(): string + { + return parent::getLogDir().'/cache_doc_'.($this::$useSymfonyListeners ? 'listeners' : 'controller'); + } + + protected function configureContainer(ContainerBuilder $c, LoaderInterface $loader): void + { + parent::configureContainer($c, $loader); + + $loader->load(static function (ContainerBuilder $container): void { + $container->loadFromExtension('api_platform', [ + 'use_symfony_listeners' => CacheableDocumentationAppKernel::$useSymfonyListeners, + ]); + }); + } +} + +final class CacheableDocumentationTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = true; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [CacheableDocumentationDummy::class]; + } + + protected static function getKernelClass(): string + { + return CacheableDocumentationAppKernel::class; + } + + /** + * @return iterable + */ + public static function modeProvider(): iterable + { + yield 'controller mode' => [false]; + yield 'listener mode' => [true]; + } + + #[DataProvider('modeProvider')] + public function testDocumentationResponseHasCacheHeaders(bool $useSymfonyListeners): void + { + CacheableDocumentationAppKernel::$useSymfonyListeners = $useSymfonyListeners; + + $response = self::createClient()->request('GET', '/docs.jsonld', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(200); + $headers = $response->getHeaders(); + $this->assertNotEmpty($headers['etag'][0] ?? null, 'documentation response is missing an ETag'); + $cacheControl = $headers['cache-control'][0] ?? ''; + $this->assertStringContainsString('public', $cacheControl); + $this->assertStringContainsString('max-age=0', $cacheControl); + $this->assertStringContainsString('must-revalidate', $cacheControl); + } + + #[DataProvider('modeProvider')] + public function testEntrypointResponseHasCacheHeaders(bool $useSymfonyListeners): void + { + CacheableDocumentationAppKernel::$useSymfonyListeners = $useSymfonyListeners; + + $response = self::createClient()->request('GET', '/index.jsonld', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(200); + $headers = $response->getHeaders(); + $this->assertNotEmpty($headers['etag'][0] ?? null, 'entrypoint response is missing an ETag'); + $cacheControl = $headers['cache-control'][0] ?? ''; + $this->assertStringContainsString('max-age=0', $cacheControl); + $this->assertStringContainsString('must-revalidate', $cacheControl); + } + + #[DataProvider('modeProvider')] + public function testDocumentationReturnsNotModifiedWhenIfNoneMatchMatches(bool $useSymfonyListeners): void + { + CacheableDocumentationAppKernel::$useSymfonyListeners = $useSymfonyListeners; + + $client = self::createClient(); + $first = $client->request('GET', '/docs.jsonld', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $etag = $first->getHeaders()['etag'][0] ?? null; + $this->assertNotEmpty($etag, 'expected an ETag on the first documentation response'); + + $client->request('GET', '/docs.jsonld', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'If-None-Match' => $etag, + ], + ]); + $this->assertResponseStatusCodeSame(304); + } + + #[DataProvider('modeProvider')] + public function testRegularResourceDoesNotHaveDocumentationCacheHeaders(bool $useSymfonyListeners): void + { + CacheableDocumentationAppKernel::$useSymfonyListeners = $useSymfonyListeners; + + $response = self::createClient()->request('GET', '/cacheable_documentation_dummies'); + + $this->assertResponseStatusCodeSame(200); + $headers = $response->getHeaders(); + $cacheControl = $headers['cache-control'][0] ?? ''; + $this->assertStringNotContainsString('must-revalidate', $cacheControl, 'regular resource must not be wrapped by the documentation cache decorator'); + $this->assertStringNotContainsString('max-age=0', $cacheControl, 'regular resource must not be wrapped by the documentation cache decorator'); + } +} From 456d96de6c24b0f5a1978238e9573c3e38560f75 Mon Sep 17 00:00:00 2001 From: soyuka Date: Tue, 28 Apr 2026 13:24:08 +0200 Subject: [PATCH 3/5] fix(laravel): cache documentation --- src/Laravel/ApiPlatformProvider.php | 11 ++- .../Tests/CacheableDocumentationTest.php | 68 +++++++++++++++++++ 2 files changed, 77 insertions(+), 2 deletions(-) create mode 100644 src/Laravel/Tests/CacheableDocumentationTest.php diff --git a/src/Laravel/ApiPlatformProvider.php b/src/Laravel/ApiPlatformProvider.php index 8eac85f278..205ebec99d 100644 --- a/src/Laravel/ApiPlatformProvider.php +++ b/src/Laravel/ApiPlatformProvider.php @@ -158,6 +158,7 @@ use ApiPlatform\State\Pagination\Pagination; use ApiPlatform\State\Pagination\PaginationOptions; use ApiPlatform\State\Processor\AddLinkHeaderProcessor; +use ApiPlatform\State\Processor\CacheableDocumentationProcessor; use ApiPlatform\State\Processor\ObjectMapperInputProcessor; use ApiPlatform\State\Processor\ObjectMapperOutputProcessor; use ApiPlatform\State\Processor\RespondProcessor; @@ -551,6 +552,12 @@ public function register(): void }); } + // Documentation/entrypoint processor wraps the base ProcessorInterface with the cache decorator. + // MainController and the rest keep the bare ProcessorInterface so regular resource responses are not cached as docs. + $this->app->bind('api_platform.state_processor.documentation', static function (Application $app) { + return new CacheableDocumentationProcessor($app->make(ProcessorInterface::class)); + }); + $this->app->singleton(ObjectNormalizer::class, static function (Application $app) { $config = $app['config']; $defaultContext = $config->get('api-platform.serializer', []); @@ -779,7 +786,7 @@ public function register(): void version: $config->get('api-platform.version') ?? '', openApiFactory: $app->make(OpenApiFactoryInterface::class), provider: $app->make(ProviderInterface::class), - processor: $app->make(ProcessorInterface::class), + processor: $app->make('api_platform.state_processor.documentation'), negotiator: $app->make(Negotiator::class), documentationFormats: $config->get('api-platform.docs_formats'), swaggerUiEnabled: $config->get('api-platform.swagger_ui.enabled', false), @@ -792,7 +799,7 @@ public function register(): void /** @var ConfigRepository */ $config = $app['config']; - return new EntrypointController($app->make(ResourceNameCollectionFactoryInterface::class), $app->make(ProviderInterface::class), $app->make(ProcessorInterface::class), $config->get('api-platform.docs_formats')); + return new EntrypointController($app->make(ResourceNameCollectionFactoryInterface::class), $app->make(ProviderInterface::class), $app->make('api_platform.state_processor.documentation'), $config->get('api-platform.docs_formats')); }); $this->app->singleton(Pagination::class, static function (Application $app) { diff --git a/src/Laravel/Tests/CacheableDocumentationTest.php b/src/Laravel/Tests/CacheableDocumentationTest.php new file mode 100644 index 0000000000..543c7dedd8 --- /dev/null +++ b/src/Laravel/Tests/CacheableDocumentationTest.php @@ -0,0 +1,68 @@ + + * + * 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\Laravel\Tests; + +use ApiPlatform\Laravel\Test\ApiTestAssertionsTrait; +use Orchestra\Testbench\Concerns\WithWorkbench; +use Orchestra\Testbench\TestCase; + +class CacheableDocumentationTest extends TestCase +{ + use ApiTestAssertionsTrait; + use WithWorkbench; + + public function testDocumentationResponseHasCacheHeaders(): void + { + $response = $this->get('/api/docs.jsonld'); + $response->assertStatus(200); + + $etag = $response->headers->get('etag'); + $this->assertNotEmpty($etag, 'documentation response is missing an ETag'); + + $cacheControl = $response->headers->get('cache-control') ?? ''; + $this->assertStringContainsString('public', $cacheControl); + $this->assertStringContainsString('max-age=0', $cacheControl); + $this->assertStringContainsString('must-revalidate', $cacheControl); + } + + public function testEntrypointResponseHasCacheHeaders(): void + { + $response = $this->get('/api/index.jsonld'); + $response->assertStatus(200); + + $cacheControl = $response->headers->get('cache-control') ?? ''; + $this->assertStringContainsString('max-age=0', $cacheControl); + $this->assertStringContainsString('must-revalidate', $cacheControl); + } + + public function testDocumentationReturnsNotModifiedWhenIfNoneMatchMatches(): void + { + $first = $this->get('/api/docs.jsonld'); + $etag = $first->headers->get('etag'); + $this->assertNotEmpty($etag, 'expected an ETag on the first documentation response'); + + $second = $this->get('/api/docs.jsonld', ['If-None-Match' => $etag]); + $second->assertStatus(304); + } + + public function testRegularResourceDoesNotHaveDocumentationCacheHeaders(): void + { + $response = $this->get('/api/staff', headers: ['accept' => 'application/ld+json']); + $response->assertStatus(200); + + $cacheControl = $response->headers->get('cache-control') ?? ''; + $this->assertStringNotContainsString('must-revalidate', $cacheControl, 'regular resource must not be wrapped by the documentation cache decorator'); + $this->assertStringNotContainsString('max-age=0', $cacheControl, 'regular resource must not be wrapped by the documentation cache decorator'); + } +} From d5c02244c0d10a3f54ea10868df9d087de6dd857 Mon Sep 17 00:00:00 2001 From: soyuka Date: Tue, 28 Apr 2026 13:34:07 +0200 Subject: [PATCH 4/5] feat: add configuration for docs cache --- src/Laravel/ApiPlatformProvider.php | 16 +++- .../Tests/CacheableDocumentationTest.php | 53 ++++++++++ src/Laravel/config/api-platform.php | 13 +++ .../CacheableDocumentationProcessor.php | 35 +++++-- .../CacheableDocumentationProcessorTest.php | 51 ++++++++++ .../ApiPlatformExtension.php | 12 +++ .../DependencyInjection/Configuration.php | 26 +++++ .../Resources/config/symfony/controller.php | 9 +- .../Resources/config/symfony/events.php | 9 +- .../State/CacheableDocumentationTest.php | 96 ++++++++++++++++++- 10 files changed, 306 insertions(+), 14 deletions(-) diff --git a/src/Laravel/ApiPlatformProvider.php b/src/Laravel/ApiPlatformProvider.php index 205ebec99d..32caa6247c 100644 --- a/src/Laravel/ApiPlatformProvider.php +++ b/src/Laravel/ApiPlatformProvider.php @@ -555,7 +555,21 @@ public function register(): void // Documentation/entrypoint processor wraps the base ProcessorInterface with the cache decorator. // MainController and the rest keep the bare ProcessorInterface so regular resource responses are not cached as docs. $this->app->bind('api_platform.state_processor.documentation', static function (Application $app) { - return new CacheableDocumentationProcessor($app->make(ProcessorInterface::class)); + $cfg = $app['config']->get('api-platform.documentation.cache_headers', []); + $base = $app->make(ProcessorInterface::class); + + if (false === ($cfg['enabled'] ?? true)) { + return $base; + } + + return new CacheableDocumentationProcessor( + $base, + $cfg['max_age'] ?? 0, + $cfg['shared_max_age'] ?? null, + $cfg['public'] ?? true, + $cfg['must_revalidate'] ?? true, + $cfg['etag'] ?? true, + ); }); $this->app->singleton(ObjectNormalizer::class, static function (Application $app) { diff --git a/src/Laravel/Tests/CacheableDocumentationTest.php b/src/Laravel/Tests/CacheableDocumentationTest.php index 543c7dedd8..b235f7c3f4 100644 --- a/src/Laravel/Tests/CacheableDocumentationTest.php +++ b/src/Laravel/Tests/CacheableDocumentationTest.php @@ -65,4 +65,57 @@ public function testRegularResourceDoesNotHaveDocumentationCacheHeaders(): void $this->assertStringNotContainsString('must-revalidate', $cacheControl, 'regular resource must not be wrapped by the documentation cache decorator'); $this->assertStringNotContainsString('max-age=0', $cacheControl, 'regular resource must not be wrapped by the documentation cache decorator'); } + + public function testCustomMaxAgeAndSharedMaxAgeAreApplied(): void + { + $this->app['config']->set('api-platform.documentation.cache_headers.max_age', 3600); + $this->app['config']->set('api-platform.documentation.cache_headers.shared_max_age', 600); + $this->app->forgetInstance('api_platform.state_processor.documentation'); + + $response = $this->get('/api/docs.jsonld'); + $response->assertStatus(200); + + $cacheControl = $response->headers->get('cache-control') ?? ''; + $this->assertStringContainsString('max-age=3600', $cacheControl); + $this->assertStringContainsString('s-maxage=600', $cacheControl); + } + + public function testMustRevalidateCanBeDisabled(): void + { + $this->app['config']->set('api-platform.documentation.cache_headers.must_revalidate', false); + $this->app->forgetInstance('api_platform.state_processor.documentation'); + + $response = $this->get('/api/docs.jsonld'); + $response->assertStatus(200); + + $cacheControl = $response->headers->get('cache-control') ?? ''; + $this->assertStringNotContainsString('must-revalidate', $cacheControl); + } + + public function testEtagCanBeDisabled(): void + { + $this->app['config']->set('api-platform.documentation.cache_headers.etag', false); + $this->app->forgetInstance('api_platform.state_processor.documentation'); + + $response = $this->get('/api/docs.jsonld'); + $response->assertStatus(200); + + // documentation decorator stays wired (must-revalidate proves it) but its md5 ETag must not be set + $cacheControl = $response->headers->get('cache-control') ?? ''; + $this->assertStringContainsString('must-revalidate', $cacheControl); + $etag = trim($response->headers->get('etag') ?? '', '"'); + $this->assertFalse(1 === preg_match('/^[a-f0-9]{32}$/', $etag), 'documentation decorator must not produce its md5 ETag when etag is disabled'); + } + + public function testFeatureCanBeDisabled(): void + { + $this->app['config']->set('api-platform.documentation.cache_headers.enabled', false); + $this->app->forgetInstance('api_platform.state_processor.documentation'); + + $response = $this->get('/api/docs.jsonld'); + $response->assertStatus(200); + + $cacheControl = $response->headers->get('cache-control') ?? ''; + $this->assertStringNotContainsString('must-revalidate', $cacheControl, 'when disabled, the cache decorator must not be wired'); + } } diff --git a/src/Laravel/config/api-platform.php b/src/Laravel/config/api-platform.php index 2db701be66..d747ed3e52 100644 --- a/src/Laravel/config/api-platform.php +++ b/src/Laravel/config/api-platform.php @@ -182,6 +182,19 @@ // ], // ], + // HTTP cache headers added to documentation and entrypoint responses (e.g. /api/docs, /api). + // Unrelated to the resource-level "http_cache" block above which targets API resource responses. + 'documentation' => [ + 'cache_headers' => [ + 'enabled' => true, + 'max_age' => 0, + 'shared_max_age' => null, + 'public' => true, + 'must_revalidate' => true, + 'etag' => true, + ], + ], + 'error_handler' => [ 'extend_laravel_handler' => true, ], diff --git a/src/State/Processor/CacheableDocumentationProcessor.php b/src/State/Processor/CacheableDocumentationProcessor.php index 6496b426e0..c7a02cbd6e 100644 --- a/src/State/Processor/CacheableDocumentationProcessor.php +++ b/src/State/Processor/CacheableDocumentationProcessor.php @@ -37,8 +37,14 @@ final class CacheableDocumentationProcessor implements ProcessorInterface, Stopw /** * @param ProcessorInterface $decorated */ - public function __construct(private readonly ProcessorInterface $decorated) - { + public function __construct( + private readonly ProcessorInterface $decorated, + private readonly int $maxAge = 0, + private readonly ?int $sharedMaxAge = null, + private readonly bool $public = true, + private readonly bool $mustRevalidate = true, + private readonly bool $etag = true, + ) { } public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed @@ -56,12 +62,27 @@ public function process(mixed $data, Operation $operation, array $uriVariables = $this->stopwatch?->start('api_platform.processor.cacheable_documentation'); - $response->setEtag(md5($content)); - $response->setPublic(); - $response->setMaxAge(0); - $response->headers->addCacheControlDirective('must-revalidate'); + if ($this->etag) { + $response->setEtag(md5($content)); + } + + if ($this->public) { + $response->setPublic(); + } else { + $response->setPrivate(); + } + + $response->setMaxAge($this->maxAge); + + if (null !== $this->sharedMaxAge) { + $response->setSharedMaxAge($this->sharedMaxAge); + } + + if ($this->mustRevalidate) { + $response->headers->addCacheControlDirective('must-revalidate'); + } - if (($request = $context['request'] ?? null) instanceof Request) { + if ($this->etag && ($request = $context['request'] ?? null) instanceof Request) { $response->isNotModified($request); } diff --git a/src/State/Tests/Processor/CacheableDocumentationProcessorTest.php b/src/State/Tests/Processor/CacheableDocumentationProcessorTest.php index 64c28e068d..762e14ad5a 100644 --- a/src/State/Tests/Processor/CacheableDocumentationProcessorTest.php +++ b/src/State/Tests/Processor/CacheableDocumentationProcessorTest.php @@ -16,6 +16,7 @@ use ApiPlatform\Metadata\Get; use ApiPlatform\State\Processor\CacheableDocumentationProcessor; use ApiPlatform\State\ProcessorInterface; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -95,6 +96,56 @@ public function testItStillSetsHeadersWhenRequestIsAbsent(): void $this->assertSame(200, $response->getStatusCode()); } + public function testItHonorsCustomMaxAge(): void + { + $processor = new CacheableDocumentationProcessor($this->decoratedReturning(new Response('body')), maxAge: 3600); + + $response = $processor->process(new \stdClass(), new Get(), [], ['request' => new Request()]); + + $this->assertSame(3600, (int) $response->headers->getCacheControlDirective('max-age')); + } + + public function testItHonorsSharedMaxAge(): void + { + $processor = new CacheableDocumentationProcessor($this->decoratedReturning(new Response('body')), sharedMaxAge: 600); + + $response = $processor->process(new \stdClass(), new Get(), [], ['request' => new Request()]); + + $this->assertSame(600, (int) $response->headers->getCacheControlDirective('s-maxage')); + } + + public function testItSetsPrivateWhenPublicIsFalse(): void + { + $processor = new CacheableDocumentationProcessor($this->decoratedReturning(new Response('body')), public: false); + + $response = $processor->process(new \stdClass(), new Get(), [], ['request' => new Request()]); + + $this->assertFalse($response->headers->hasCacheControlDirective('public')); + $this->assertTrue($response->headers->hasCacheControlDirective('private')); + } + + public function testItOmitsMustRevalidateWhenDisabled(): void + { + $processor = new CacheableDocumentationProcessor($this->decoratedReturning(new Response('body')), mustRevalidate: false); + + $response = $processor->process(new \stdClass(), new Get(), [], ['request' => new Request()]); + + $this->assertFalse($response->headers->hasCacheControlDirective('must-revalidate')); + } + + public function testItOmitsEtagWhenDisabled(): void + { + $body = 'body'; + $request = new Request(); + $request->headers->set('If-None-Match', '"'.md5($body).'"'); + $processor = new CacheableDocumentationProcessor($this->decoratedReturning(new Response($body)), etag: false); + + $response = $processor->process(new \stdClass(), new Get(), [], ['request' => $request]); + + $this->assertNull($response->getEtag()); + $this->assertSame(200, $response->getStatusCode(), 'When etag is disabled, isNotModified short-circuit must not run'); + } + private function decoratedReturning(mixed $value): ProcessorInterface { $decorated = $this->createStub(ProcessorInterface::class); diff --git a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index 01c696c4e7..abc3bc1da4 100644 --- a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -338,6 +338,14 @@ private function registerCommonConfiguration(ContainerBuilder $container, array if (!$container->hasParameter('serializer.default_context')) { $container->setParameter('serializer.default_context', $container->getParameter('api_platform.serializer.default_context')); } + $documentationCacheHeaders = $config['documentation']['cache_headers']; + $container->setParameter('api_platform.documentation.cache_headers.enabled', $documentationCacheHeaders['enabled']); + $container->setParameter('api_platform.documentation.cache_headers.max_age', $documentationCacheHeaders['max_age']); + $container->setParameter('api_platform.documentation.cache_headers.shared_max_age', $documentationCacheHeaders['shared_max_age']); + $container->setParameter('api_platform.documentation.cache_headers.public', $documentationCacheHeaders['public']); + $container->setParameter('api_platform.documentation.cache_headers.must_revalidate', $documentationCacheHeaders['must_revalidate']); + $container->setParameter('api_platform.documentation.cache_headers.etag', $documentationCacheHeaders['etag']); + if ($config['use_symfony_listeners']) { $loader->load('symfony/events.php'); } else { @@ -347,6 +355,10 @@ private function registerCommonConfiguration(ContainerBuilder $container, array } $loader->load('state/parameter_provider.php'); + if (!$documentationCacheHeaders['enabled'] && $container->hasDefinition('api_platform.state_processor.documentation.cache')) { + $container->removeDefinition('api_platform.state_processor.documentation.cache'); + } + $container->setParameter('api_platform.enable_entrypoint', $config['enable_entrypoint']); $container->setParameter('api_platform.enable_docs', $config['enable_docs']); $container->setParameter('api_platform.title', $config['title']); diff --git a/src/Symfony/Bundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/DependencyInjection/Configuration.php index 911de422fd..c4083fa9a1 100644 --- a/src/Symfony/Bundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/DependencyInjection/Configuration.php @@ -182,6 +182,7 @@ public function getConfigTreeBuilder(): TreeBuilder $this->addGraphQlSection($rootNode); $this->addSwaggerSection($rootNode); $this->addHttpCacheSection($rootNode); + $this->addDocumentationSection($rootNode); $this->addMercureSection($rootNode); $this->addMessengerSection($rootNode); $this->addElasticsearchSection($rootNode); @@ -456,6 +457,31 @@ private function addHttpCacheSection(ArrayNodeDefinition $rootNode): void ->end(); } + private function addDocumentationSection(ArrayNodeDefinition $rootNode): void + { + $rootNode + ->children() + ->arrayNode('documentation') + ->info('Documentation/entrypoint response options. Unrelated to the operation-level "defaults.cache_headers" / "http_cache" knobs which apply to API resource responses.') + ->addDefaultsIfNotSet() + ->children() + ->arrayNode('cache_headers') + ->info('HTTP cache headers added to documentation and entrypoint responses.') + ->canBeDisabled() + ->addDefaultsIfNotSet() + ->children() + ->integerNode('max_age')->defaultValue(0)->info('Cache-Control max-age, in seconds.')->end() + ->integerNode('shared_max_age')->defaultNull()->info('Cache-Control s-maxage, in seconds. Useful for CDN edges.')->end() + ->booleanNode('public')->defaultTrue()->info('Whether the response is public (shared caches may store it).')->end() + ->booleanNode('must_revalidate')->defaultTrue()->info('Whether to add the must-revalidate Cache-Control directive.')->end() + ->booleanNode('etag')->defaultTrue()->info('Whether to add a content-hash ETag header (and short-circuit to 304 on If-None-Match).')->end() + ->end() + ->end() + ->end() + ->end() + ->end(); + } + private function addMercureSection(ArrayNodeDefinition $rootNode): void { $rootNode diff --git a/src/Symfony/Bundle/Resources/config/symfony/controller.php b/src/Symfony/Bundle/Resources/config/symfony/controller.php index d5a20ad7bd..000031392e 100644 --- a/src/Symfony/Bundle/Resources/config/symfony/controller.php +++ b/src/Symfony/Bundle/Resources/config/symfony/controller.php @@ -35,7 +35,14 @@ $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')]); + ->args([ + service('api_platform.state_processor.documentation.cache.inner'), + '%api_platform.documentation.cache_headers.max_age%', + '%api_platform.documentation.cache_headers.shared_max_age%', + '%api_platform.documentation.cache_headers.public%', + '%api_platform.documentation.cache_headers.must_revalidate%', + '%api_platform.documentation.cache_headers.etag%', + ]); $services->set('api_platform.action.entrypoint', EntrypointAction::class) ->public() diff --git a/src/Symfony/Bundle/Resources/config/symfony/events.php b/src/Symfony/Bundle/Resources/config/symfony/events.php index 2a464991af..1d46a63212 100644 --- a/src/Symfony/Bundle/Resources/config/symfony/events.php +++ b/src/Symfony/Bundle/Resources/config/symfony/events.php @@ -148,7 +148,14 @@ $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')]); + ->args([ + service('api_platform.state_processor.documentation.cache.inner'), + '%api_platform.documentation.cache_headers.max_age%', + '%api_platform.documentation.cache_headers.shared_max_age%', + '%api_platform.documentation.cache_headers.public%', + '%api_platform.documentation.cache_headers.must_revalidate%', + '%api_platform.documentation.cache_headers.etag%', + ]); $services->set('api_platform.state_processor.documentation.serialize', SerializeProcessor::class) ->decorate('api_platform.state_processor.documentation', null, 200) diff --git a/tests/Functional/State/CacheableDocumentationTest.php b/tests/Functional/State/CacheableDocumentationTest.php index 81de8dd58b..aa96155b9c 100644 --- a/tests/Functional/State/CacheableDocumentationTest.php +++ b/tests/Functional/State/CacheableDocumentationTest.php @@ -24,14 +24,19 @@ class CacheableDocumentationAppKernel extends \AppKernel { public static bool $useSymfonyListeners = false; + /** + * @var array + */ + public static array $cacheHeaders = []; + public function getCacheDir(): string { - return parent::getCacheDir().'/cache_doc_'.($this::$useSymfonyListeners ? 'listeners' : 'controller'); + return parent::getCacheDir().'/cache_doc_'.($this::$useSymfonyListeners ? 'listeners' : 'controller').'_'.self::cacheHeadersSignature(); } public function getLogDir(): string { - return parent::getLogDir().'/cache_doc_'.($this::$useSymfonyListeners ? 'listeners' : 'controller'); + return parent::getLogDir().'/cache_doc_'.($this::$useSymfonyListeners ? 'listeners' : 'controller').'_'.self::cacheHeadersSignature(); } protected function configureContainer(ContainerBuilder $c, LoaderInterface $loader): void @@ -39,11 +44,22 @@ protected function configureContainer(ContainerBuilder $c, LoaderInterface $load parent::configureContainer($c, $loader); $loader->load(static function (ContainerBuilder $container): void { - $container->loadFromExtension('api_platform', [ + $extensionConfig = [ 'use_symfony_listeners' => CacheableDocumentationAppKernel::$useSymfonyListeners, - ]); + ]; + + if ([] !== CacheableDocumentationAppKernel::$cacheHeaders) { + $extensionConfig['documentation'] = ['cache_headers' => CacheableDocumentationAppKernel::$cacheHeaders]; + } + + $container->loadFromExtension('api_platform', $extensionConfig); }); } + + private static function cacheHeadersSignature(): string + { + return [] === self::$cacheHeaders ? 'default' : substr(md5(serialize(self::$cacheHeaders)), 0, 8); + } } final class CacheableDocumentationTest extends ApiTestCase @@ -74,6 +90,14 @@ public static function modeProvider(): iterable yield 'listener mode' => [true]; } + protected function setUp(): void + { + parent::setUp(); + + // reset config overrides so each test starts from defaults + CacheableDocumentationAppKernel::$cacheHeaders = []; + } + #[DataProvider('modeProvider')] public function testDocumentationResponseHasCacheHeaders(bool $useSymfonyListeners): void { @@ -143,4 +167,68 @@ public function testRegularResourceDoesNotHaveDocumentationCacheHeaders(bool $us $this->assertStringNotContainsString('must-revalidate', $cacheControl, 'regular resource must not be wrapped by the documentation cache decorator'); $this->assertStringNotContainsString('max-age=0', $cacheControl, 'regular resource must not be wrapped by the documentation cache decorator'); } + + #[DataProvider('modeProvider')] + public function testCustomMaxAgeAndSharedMaxAgeAreApplied(bool $useSymfonyListeners): void + { + CacheableDocumentationAppKernel::$useSymfonyListeners = $useSymfonyListeners; + CacheableDocumentationAppKernel::$cacheHeaders = ['max_age' => 3600, 'shared_max_age' => 600]; + + $response = self::createClient()->request('GET', '/docs.jsonld', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(200); + $cacheControl = $response->getHeaders()['cache-control'][0] ?? ''; + $this->assertStringContainsString('max-age=3600', $cacheControl); + $this->assertStringContainsString('s-maxage=600', $cacheControl); + } + + #[DataProvider('modeProvider')] + public function testMustRevalidateCanBeDisabled(bool $useSymfonyListeners): void + { + CacheableDocumentationAppKernel::$useSymfonyListeners = $useSymfonyListeners; + CacheableDocumentationAppKernel::$cacheHeaders = ['must_revalidate' => false]; + + $response = self::createClient()->request('GET', '/docs.jsonld', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(200); + $cacheControl = $response->getHeaders()['cache-control'][0] ?? ''; + $this->assertStringNotContainsString('must-revalidate', $cacheControl); + } + + #[DataProvider('modeProvider')] + public function testEtagCanBeDisabled(bool $useSymfonyListeners): void + { + CacheableDocumentationAppKernel::$useSymfonyListeners = $useSymfonyListeners; + CacheableDocumentationAppKernel::$cacheHeaders = ['etag' => false]; + + $response = self::createClient()->request('GET', '/docs.jsonld', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(200); + // documentation decorator is still wired (must-revalidate proves it) but it must not have set its md5 ETag + $cacheControl = $response->getHeaders()['cache-control'][0] ?? ''; + $this->assertStringContainsString('must-revalidate', $cacheControl); + $etag = trim($response->getHeaders()['etag'][0] ?? '', '"'); + $this->assertFalse(1 === preg_match('/^[a-f0-9]{32}$/', $etag), 'documentation decorator must not produce its md5 ETag when etag is disabled'); + } + + #[DataProvider('modeProvider')] + public function testFeatureCanBeDisabled(bool $useSymfonyListeners): void + { + CacheableDocumentationAppKernel::$useSymfonyListeners = $useSymfonyListeners; + CacheableDocumentationAppKernel::$cacheHeaders = ['enabled' => false]; + + $response = self::createClient()->request('GET', '/docs.jsonld', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(200); + $cacheControl = $response->getHeaders()['cache-control'][0] ?? ''; + $this->assertStringNotContainsString('must-revalidate', $cacheControl, 'when disabled, the cache decorator must not be wired'); + } } From cc18ba101beee28a28b893076d9ddad2ccd5909a Mon Sep 17 00:00:00 2001 From: soyuka Date: Tue, 28 Apr 2026 13:43:00 +0200 Subject: [PATCH 5/5] test: default config test --- .../Processor/CacheableDocumentationProcessorTest.php | 1 - .../Bundle/DependencyInjection/ConfigurationTest.php | 10 ++++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/State/Tests/Processor/CacheableDocumentationProcessorTest.php b/src/State/Tests/Processor/CacheableDocumentationProcessorTest.php index 762e14ad5a..7beb430a24 100644 --- a/src/State/Tests/Processor/CacheableDocumentationProcessorTest.php +++ b/src/State/Tests/Processor/CacheableDocumentationProcessorTest.php @@ -16,7 +16,6 @@ use ApiPlatform\Metadata\Get; use ApiPlatform\State\Processor\CacheableDocumentationProcessor; use ApiPlatform\State\ProcessorInterface; -use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; diff --git a/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php b/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php index 9004ecb665..d654e7badd 100644 --- a/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php +++ b/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php @@ -198,6 +198,16 @@ private function runDefaultConfigTests(array $doctrineIntegrationsToLoad = ['orm ], 'public' => null, ], + 'documentation' => [ + 'cache_headers' => [ + 'enabled' => true, + 'max_age' => 0, + 'shared_max_age' => null, + 'public' => true, + 'must_revalidate' => true, + 'etag' => true, + ], + ], 'doctrine' => [ 'enabled' => \in_array('orm', $doctrineIntegrationsToLoad, true), ],