diff --git a/src/Laravel/ApiPlatformProvider.php b/src/Laravel/ApiPlatformProvider.php index 8eac85f278..32caa6247c 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,26 @@ 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) { + $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) { $config = $app['config']; $defaultContext = $config->get('api-platform.serializer', []); @@ -779,7 +800,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 +813,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..b235f7c3f4 --- /dev/null +++ b/src/Laravel/Tests/CacheableDocumentationTest.php @@ -0,0 +1,121 @@ + + * + * 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'); + } + + 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 new file mode 100644 index 0000000000..c7a02cbd6e --- /dev/null +++ b/src/State/Processor/CacheableDocumentationProcessor.php @@ -0,0 +1,93 @@ + + * + * 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, + 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 + { + $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'); + + 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 ($this->etag && ($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..7beb430a24 --- /dev/null +++ b/src/State/Tests/Processor/CacheableDocumentationProcessorTest.php @@ -0,0 +1,155 @@ + + * + * 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()); + } + + 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); + $decorated->method('process')->willReturn($value); + + return $decorated; + } +} 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 2ac24e613e..000031392e 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,25 @@ 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'), + '%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() ->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 +62,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/src/Symfony/Bundle/Resources/config/symfony/events.php b/src/Symfony/Bundle/Resources/config/symfony/events.php index c8c2c833e7..1d46a63212 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,17 @@ $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'), + '%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) ->args([ 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..aa96155b9c --- /dev/null +++ b/tests/Functional/State/CacheableDocumentationTest.php @@ -0,0 +1,234 @@ + + * + * 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; + + /** + * @var array + */ + public static array $cacheHeaders = []; + + public function getCacheDir(): string + { + return parent::getCacheDir().'/cache_doc_'.($this::$useSymfonyListeners ? 'listeners' : 'controller').'_'.self::cacheHeadersSignature(); + } + + public function getLogDir(): string + { + return parent::getLogDir().'/cache_doc_'.($this::$useSymfonyListeners ? 'listeners' : 'controller').'_'.self::cacheHeadersSignature(); + } + + protected function configureContainer(ContainerBuilder $c, LoaderInterface $loader): void + { + parent::configureContainer($c, $loader); + + $loader->load(static function (ContainerBuilder $container): void { + $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 +{ + 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]; + } + + 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 + { + 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'); + } + + #[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'); + } +} 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), ],