Skip to content

Commit 4d03674

Browse files
committed
fix(laravel): http cache compatibility
1 parent 949c3c9 commit 4d03674

File tree

7 files changed

+330
-6
lines changed

7 files changed

+330
-6
lines changed

src/HttpCache/SouinPurger.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,8 @@ class SouinPurger extends SurrogateKeysPurger
2929
/**
3030
* @param HttpClientInterface[] $clients
3131
*/
32-
public function __construct(iterable $clients)
32+
public function __construct(iterable $clients, int $maxHeaderLength = self::MAX_HEADER_SIZE_PER_BATCH)
3333
{
34-
parent::__construct($clients, self::MAX_HEADER_SIZE_PER_BATCH, self::HEADER, self::SEPARATOR);
34+
parent::__construct($clients, $maxHeaderLength, self::HEADER, self::SEPARATOR);
3535
}
3636
}

src/HttpCache/State/AddHeadersProcessor.php

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,16 @@ final class AddHeadersProcessor implements ProcessorInterface
2929
/**
3030
* @param ProcessorInterface<T1, T2> $decorated
3131
*/
32-
public function __construct(private readonly ProcessorInterface $decorated, private readonly bool $etag = false, private readonly ?int $maxAge = null, private readonly ?int $sharedMaxAge = null, private readonly ?array $vary = null, private readonly ?bool $public = null, private readonly ?int $staleWhileRevalidate = null, private readonly ?int $staleIfError = null)
32+
public function __construct(
33+
private readonly ProcessorInterface $decorated,
34+
private readonly bool $etag = false,
35+
private readonly ?int $maxAge = null,
36+
private readonly ?int $sharedMaxAge = null,
37+
private readonly ?array $vary = null,
38+
private readonly ?bool $public = null,
39+
private readonly ?int $staleWhileRevalidate = null,
40+
private readonly ?int $staleIfError = null,
41+
)
3342
{
3443
}
3544

src/Laravel/ApiPlatformProvider.php

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
use ApiPlatform\Hal\Serializer\EntrypointNormalizer as HalEntrypointNormalizer;
5050
use ApiPlatform\Hal\Serializer\ItemNormalizer as HalItemNormalizer;
5151
use ApiPlatform\Hal\Serializer\ObjectNormalizer as HalObjectNormalizer;
52+
use ApiPlatform\HttpCache\State\AddHeadersProcessor;
5253
use ApiPlatform\Hydra\JsonSchema\SchemaFactory as HydraSchemaFactory;
5354
use ApiPlatform\Hydra\Serializer\CollectionFiltersNormalizer as HydraCollectionFiltersNormalizer;
5455
use ApiPlatform\Hydra\Serializer\CollectionNormalizer as HydraCollectionNormalizer;
@@ -403,8 +404,25 @@ public function register(): void
403404

404405
$this->app->bind(ProviderInterface::class, ContentNegotiationProvider::class);
405406

406-
$this->app->singleton(RespondProcessor::class, function () {
407-
return new AddLinkHeaderProcessor(new RespondProcessor(), new HttpHeaderSerializer());
407+
$this->app->singleton(RespondProcessor::class, function (Application $app) {
408+
$decorated = new RespondProcessor();
409+
if (class_exists(AddHeadersProcessor::class)) {
410+
/** @var ConfigRepository */
411+
$config = $app['config']['http_cache'] ?? [];
412+
413+
$decorated = new AddHeadersProcessor(
414+
$decorated,
415+
etag: $config['etag'] ?? false,
416+
maxAge: $config['max_age'] ?? null,
417+
sharedMaxAge: $config['shared_max_age'] ?? null,
418+
vary: $config['vary'] ?? null,
419+
public: $config['public'] ?? null,
420+
staleWhileRevalidate: $config['stale_while_revalidate'] ?? null,
421+
staleIfError: $config['stale_if_error']
422+
);
423+
}
424+
425+
return new AddLinkHeaderProcessor($decorated, new HttpHeaderSerializer());
408426
});
409427

410428
$this->app->singleton(SerializeProcessor::class, function (Application $app) {
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Laravel\Eloquent;
15+
16+
use ApiPlatform\HttpCache\PurgerInterface;
17+
use ApiPlatform\HttpCache\SouinPurger;
18+
use ApiPlatform\HttpCache\VarnishPurger;
19+
use ApiPlatform\HttpCache\VarnishXKeyPurger;
20+
use ApiPlatform\Laravel\Eloquent\Listener\PurgeHttpCacheListener;
21+
use ApiPlatform\Metadata\IriConverterInterface;
22+
use ApiPlatform\Metadata\ResourceClassResolverInterface;
23+
use Illuminate\Contracts\Foundation\Application;
24+
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
25+
use Illuminate\Support\Facades\Event;
26+
use Symfony\Component\HttpClient\HttpClient;
27+
28+
class ApiPlatformEventProvider extends ServiceProvider
29+
{
30+
/**
31+
* @var array<class-string, array<int, class-string>>
32+
*/
33+
protected $listen = [];
34+
35+
public function register(): void
36+
{
37+
if (!class_exists(PurgerInterface::class)) {
38+
return;
39+
}
40+
41+
$this->app->singleton('api_platform.http_cache.clients_array', function (Application $app) {
42+
$purgerUrls = $app['config']->get('api-platform.http_cache.invalidation.urls', []);
43+
$requestOptions = $app['config']->get('api-platform.http_cache.invalidation.request_options', []);
44+
45+
$clients = [];
46+
foreach ($purgerUrls as $url) {
47+
$clients[] = HttpClient::create(array_merge($requestOptions, ['base_uri' => $url]));
48+
}
49+
50+
return $clients;
51+
});
52+
53+
$httpClients = fn (Application $app) => $app->make('api_platform.http_cache.clients_array');
54+
55+
$this->app->singleton(VarnishPurger::class, function (Application $app) use ($httpClients) {
56+
return new VarnishPurger($httpClients($app));
57+
});
58+
59+
$this->app->singleton(VarnishXKeyPurger::class, function (Application $app) use ($httpClients) {
60+
return new VarnishXKeyPurger(
61+
$httpClients($app),
62+
$app['config']->get('api-platform.http_cache.invalidation.max_header_length', 7500),
63+
$app['config']->get('api-platform.http_cache.invalidation.xkey.glue', ' ')
64+
);
65+
});
66+
67+
$this->app->singleton(SouinPurger::class, function (Application $app) use ($httpClients) {
68+
return new SouinPurger(
69+
$httpClients($app),
70+
$app['config']->get('api-platform.http_cache.invalidation.max_header_length', 7500)
71+
);
72+
});
73+
74+
$this->app->singleton(PurgerInterface::class, function (Application $app) {
75+
$purgerClass = $app['config']->get(
76+
'api-platform.http_cache.invalidation.purger',
77+
SouinPurger::class
78+
);
79+
80+
if (!class_exists($purgerClass)) {
81+
throw new \InvalidArgumentException("Purger class '{$purgerClass}' configured in api-platform.php was not found.");
82+
}
83+
84+
return $app->make($purgerClass);
85+
});
86+
87+
$this->app->singleton(PurgeHttpCacheListener::class, function (Application $app) {
88+
return new PurgeHttpCacheListener(
89+
$app->make(PurgerInterface::class),
90+
$app->make(IriConverterInterface::class),
91+
$app->make(ResourceClassResolverInterface::class)
92+
);
93+
});
94+
}
95+
96+
public function boot(): void
97+
{
98+
if (!class_exists(PurgerInterface::class)) {
99+
return;
100+
}
101+
102+
Event::listen('eloquent.saved: *', [PurgeHttpCacheListener::class, 'handleModelSaved']);
103+
Event::listen('eloquent.deleted: *', [PurgeHttpCacheListener::class, 'handleModelDeleted']);
104+
$this->app->terminating(function (): void {
105+
$this->app->make(PurgeHttpCacheListener::class)->postFlush();
106+
});
107+
}
108+
109+
public function shouldDiscoverEvents(): bool
110+
{
111+
return false;
112+
}
113+
}
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Laravel\Eloquent\Listener;
15+
16+
use ApiPlatform\HttpCache\PurgerInterface;
17+
use ApiPlatform\Metadata\IriConverterInterface;
18+
use ApiPlatform\Metadata\ResourceClassResolverInterface;
19+
use Illuminate\Database\Eloquent\Model;
20+
use Illuminate\Database\Eloquent\Relations\Relation;
21+
use Illuminate\Support\Collection;
22+
23+
final class PurgeHttpCacheListener
24+
{
25+
private array $tags = [];
26+
27+
public function __construct(
28+
private readonly PurgerInterface $purger,
29+
private readonly IriConverterInterface $iriConverter,
30+
private readonly ResourceClassResolverInterface $resourceClassResolver,
31+
) {
32+
}
33+
34+
/**
35+
* Handle the "saved" event for any model.
36+
*/
37+
public function handleModelSaved(Model $model): void
38+
{
39+
if (!$this->resourceClassResolver->isResourceClass($model::class)) {
40+
return;
41+
}
42+
43+
$this->gatherResourceAndItemTags($model, true);
44+
$this->gatherDirtyRelationTags($model);
45+
$this->gatherRelationTags($model);
46+
}
47+
48+
/**
49+
* Handle the "deleted" event for any model.
50+
*/
51+
public function handleModelDeleted(Model $model): void
52+
{
53+
if (!$this->resourceClassResolver->isResourceClass($model::class)) {
54+
return;
55+
}
56+
57+
$this->gatherResourceAndItemTags($model, true);
58+
$this->gatherRelationTags($model);
59+
}
60+
61+
/**
62+
* Purges all collected tags at the end of the request.
63+
*/
64+
public function postFlush(): void
65+
{
66+
if (empty($this->tags)) {
67+
return;
68+
}
69+
70+
$this->purger->purge(array_values(array_unique($this->tags)));
71+
$this->tags = [];
72+
}
73+
74+
private function gatherResourceAndItemTags(Model $model, bool $purgeItem): void
75+
{
76+
try {
77+
$collectionIri = $this->iriConverter->getCollectionIriFromResourceClass($model::class);
78+
$this->tags[] = $collectionIri;
79+
80+
if ($purgeItem) {
81+
$this->addTagForItem($model);
82+
}
83+
} catch (\Exception) {
84+
// Ignore if IRI cannot be generated
85+
}
86+
}
87+
88+
private function gatherDirtyRelationTags(Model $model): void
89+
{
90+
foreach ($model->getDirty() as $key => $value) {
91+
// Check if the dirty attribute is a foreign key
92+
if (!str_ends_with($key, '_id')) {
93+
continue;
94+
}
95+
96+
$originalId = $model->getOriginal($key);
97+
$relationName = str_replace('_id', '', $key);
98+
99+
if ($originalId && method_exists($model, $relationName)) {
100+
$relation = $model->{$relationName}();
101+
if ($relation instanceof Relation) {
102+
$relatedModelClass = \get_class($relation->getRelated());
103+
if ($oldRelated = $relatedModelClass::find($originalId)) {
104+
$this->addTagForItem($oldRelated);
105+
}
106+
}
107+
}
108+
}
109+
}
110+
111+
private function gatherRelationTags(Model $model): void
112+
{
113+
$reflection = new \ReflectionClass($model);
114+
$methods = $reflection->getMethods(\ReflectionMethod::IS_PUBLIC);
115+
116+
foreach ($methods as $method) {
117+
if ($method->getNumberOfParameters() > 0 || $method->isStatic()) {
118+
continue;
119+
}
120+
121+
try {
122+
$returnValue = $method->invoke($model);
123+
if ($returnValue instanceof Relation) {
124+
$related = $model->getRelationValue($method->getName());
125+
$this->addTagsFor($related);
126+
}
127+
} catch (\Throwable) {
128+
// Not a relation method
129+
}
130+
}
131+
}
132+
133+
private function addTagsFor(mixed $value): void
134+
{
135+
if (null === $value) {
136+
return;
137+
}
138+
139+
if ($value instanceof Model) {
140+
$this->addTagForItem($value);
141+
142+
return;
143+
}
144+
145+
if ($value instanceof Collection) {
146+
foreach ($value as $item) {
147+
$this->addTagForItem($item);
148+
}
149+
}
150+
}
151+
152+
private function addTagForItem(mixed $value): void
153+
{
154+
if (!$value instanceof Model || !$this->resourceClassResolver->isResourceClass($value::class)) {
155+
return;
156+
}
157+
158+
try {
159+
$iri = $this->iriConverter->getIriFromResource($value);
160+
$this->tags[] = $iri;
161+
} catch (\Exception) {
162+
// Ignore if IRI cannot be generated
163+
}
164+
}
165+
}

src/Laravel/composer.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,13 +74,15 @@
7474
},
7575
"suggest": {
7676
"api-platform/graphql": "Enable GraphQl support.",
77+
"api-platform/http-cache": "Enable HTTP Cache support.",
7778
"phpstan/phpdoc-parser": "To support extracting metadata from PHPDoc."
7879
},
7980
"extra": {
8081
"laravel": {
8182
"providers": [
8283
"ApiPlatform\\Laravel\\ApiPlatformProvider",
83-
"ApiPlatform\\Laravel\\ApiPlatformDeferredProvider"
84+
"ApiPlatform\\Laravel\\ApiPlatformDeferredProvider",
85+
"ApiPlatform\\Laravel\\Eloquent\\ApiPlatformEventProvider"
8486
]
8587
},
8688
"branch-alias": {

src/Laravel/config/api-platform.php

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,4 +144,21 @@
144144

145145
// we recommend using "file" or "acpu"
146146
'cache' => 'file',
147+
148+
// 'http_cache' => [
149+
// 'etag' => false,
150+
// 'max_age' => null,
151+
// 'shared_max_age' => null,
152+
// 'vary' => null,
153+
// 'public' => null,
154+
// 'stale_while_revalidate' => null,
155+
// 'stale_if_error' => null,
156+
// 'invalidation' => [
157+
// 'urls' => [],
158+
// 'scoped_clients' => [],
159+
// 'max_header_length' => 7500,
160+
// 'request_options' => [],
161+
// 'purger' => ApiPlatform\HttpCache\SouinPurger::class,
162+
// ],
163+
// ]
147164
];

0 commit comments

Comments
 (0)