Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 23 additions & 2 deletions src/Laravel/ApiPlatformProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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', []);
Expand Down Expand Up @@ -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),
Expand All @@ -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) {
Expand Down
121 changes: 121 additions & 0 deletions src/Laravel/Tests/CacheableDocumentationTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* 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');
}
}
13 changes: 13 additions & 0 deletions src/Laravel/config/api-platform.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
],
Expand Down
93 changes: 93 additions & 0 deletions src/State/Processor/CacheableDocumentationProcessor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* 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<T1, T2>
*/
final class CacheableDocumentationProcessor implements ProcessorInterface, StopwatchAwareInterface
{
use StopwatchAwareTrait;

/**
* @param ProcessorInterface<T1, T2> $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;
}
}
Loading
Loading