Skip to content

Commit

Permalink
Implement dynamic URL preloading in cache strategy
Browse files Browse the repository at this point in the history
This change introduces dynamic URL preloading in cache strategy. It modifies the resource cache to accept string aliases representing predefined URL generators. These generators can then produce URLs to be included in caching. This enhancement allows for more flexible and dynamic URL caching for applications.
  • Loading branch information
Spomky committed Apr 7, 2024
1 parent 27b1587 commit 3e3ca4a
Show file tree
Hide file tree
Showing 29 changed files with 592 additions and 94 deletions.
4 changes: 3 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,15 @@
"dbrekelmans/bdi": "^1.1",
"infection/infection": "^0.28",
"phpstan/extension-installer": "^1.1",
"phpstan/phpdoc-parser": "^1.28",
"phpstan/phpstan": "^1.0",
"phpstan/phpstan-beberlei-assert": "^1.0",
"phpstan/phpstan-deprecation-rules": "^1.0",
"phpstan/phpstan-phpunit": "^1.0",
"phpstan/phpstan-strict-rules": "^1.0",
"phpunit/phpunit": "^10.1",
"phpunit/phpunit": "^10.1|^11.0",
"rector/rector": "^1.0",
"staabm/phpstan-todo-by": "^0.1.25",
"symfony/filesystem": "^6.4|^7.0",
"symfony/framework-bundle": "^6.4|^7.0",
"symfony/mime": "^6.4|^7.0",
Expand Down
12 changes: 6 additions & 6 deletions phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -231,12 +231,7 @@ parameters:
path: src/Dto/Manifest.php

-
message: "#^Class SpomkyLabs\\\\PwaBundle\\\\Dto\\\\PageCache has an uninitialized property \\$matchCallback\\. Give it default value or assign it in the constructor\\.$#"
count: 1
path: src/Dto/PageCache.php

-
message: "#^PHPDoc tag @var for property SpomkyLabs\\\\PwaBundle\\\\Dto\\\\PageCache\\:\\:\\$cacheableResponseHeaders with type array\\<string, string\\>\\|null is not subtype of native type array\\.$#"
message: "#^Class SpomkyLabs\\\\PwaBundle\\\\Dto\\\\PageCache extends @final class SpomkyLabs\\\\PwaBundle\\\\Dto\\\\ResourceCache\\.$#"
count: 1
path: src/Dto/PageCache.php

Expand All @@ -260,6 +255,11 @@ parameters:
count: 1
path: src/Dto/RelatedApplication.php

-
message: "#^Class SpomkyLabs\\\\PwaBundle\\\\Dto\\\\ResourceCache has an uninitialized property \\$matchCallback\\. Give it default value or assign it in the constructor\\.$#"
count: 1
path: src/Dto/ResourceCache.php

-
message: "#^Class SpomkyLabs\\\\PwaBundle\\\\Dto\\\\ScopeExtension has an uninitialized property \\$origin\\. Give it default value or assign it in the constructor\\.$#"
count: 1
Expand Down
5 changes: 4 additions & 1 deletion rector.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
declare(strict_types=1);

use Rector\Config\RectorConfig;
use Rector\DeadCode\Rector\ClassMethod\RemoveEmptyClassMethodRector;
use Rector\Doctrine\Set\DoctrineSetList;
use Rector\PHPUnit\Set\PHPUnitSetList;
use Rector\Set\ValueObject\LevelSetList;
Expand All @@ -26,7 +27,9 @@
]);
$config->phpVersion(PhpVersion::PHP_82);
$config->paths([__DIR__ . '/src', __DIR__ . '/tests', __DIR__ . '/ecs.php', __DIR__ . '/rector.php']);
$config->skip([__DIR__ . '/tests/Controller/DummyController.php']);
$config->skip([
RemoveEmptyClassMethodRector::class => [__DIR__ . '/tests/Controller/'],
]);
$config->parallel();
$config->importNames();
$config->importShortClasses();
Expand Down
22 changes: 22 additions & 0 deletions src/Attribute/PreloadUrl.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

declare(strict_types=1);

namespace SpomkyLabs\PwaBundle\Attribute;

use Attribute;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;

#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
final readonly class PreloadUrl
{
/**
* @param array<string, mixed> $params
*/
public function __construct(
public string $alias,
public array $params = [],
public int $pathTypeReference = UrlGeneratorInterface::ABSOLUTE_PATH,
) {
}
}
131 changes: 131 additions & 0 deletions src/Attribute/PreloadUrlCompilerPass.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
<?php

declare(strict_types=1);

namespace SpomkyLabs\PwaBundle\Attribute;

use ReflectionAttribute;
use ReflectionClass;
use ReflectionMethod;
use RuntimeException;
use SpomkyLabs\PwaBundle\CachingStrategy\PreloadUrlsTagGenerator;
use Symfony\Component\DependencyInjection\ChildDefinition;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\Routing\Attribute\Route;
use Throwable;
use function array_key_exists;
use function is_string;

final class PreloadUrlCompilerPass implements CompilerPassInterface
{
public function process(ContainerBuilder $container): void
{
foreach ($this->findAllTaggedRoutes($container) as $alias => $urls) {
$definitionId = sprintf('spomky_labs_pwa.preload_urls_tag_generator.%s', $alias);
$definition = new ChildDefinition(PreloadUrlsTagGenerator::class);
$definition
->setArguments([
'$alias' => $alias,
'$urls' => $urls,
])
->addTag('spomky_labs_pwa.preload_urls_generator')
;
$container->setDefinition($definitionId, $definition);
}
}

/**
* @return array<string, array{route: string, alias: string, params?: array<string, mixed>, pathTypeReference: int}[]>
*/
private function findAllTaggedRoutes(ContainerBuilder $container): array
{
$routes = [];
$controllers = $container->findTaggedServiceIds('controller.service_arguments');
foreach (array_keys($controllers) as $controller) {
if (! is_string($controller) || ! class_exists($controller)) {
continue;
}
$reflectionClass = new ReflectionClass($controller);
$result = $this->findAllPreloadAttributesForClass($reflectionClass);
foreach ($result as $route) {
if (! array_key_exists($route['alias'], $routes)) {
$routes[$route['alias']] = [];
}
$routes[$route['alias']][] = $route;
}
}

return $routes;
}

/**
* @param ReflectionClass<object> $reflectionClass
* @return iterable<array{alias: string, route: string, params: array<string, mixed>, pathTypeReference: int}>
*/
private function findAllPreloadAttributesForClass(ReflectionClass $reflectionClass): iterable
{
foreach ($reflectionClass->getAttributes(PreloadUrl::class, ReflectionAttribute::IS_INSTANCEOF) as $attribute) {
try {
/** @var PreloadUrl $preloadAttribute */
$preloadAttribute = $attribute->newInstance();
yield from $this->findAllRoutesToPreload(
$preloadAttribute,
$reflectionClass->getMethods(ReflectionMethod::IS_PUBLIC)
);
} catch (Throwable $e) {
throw new RuntimeException(sprintf('Unable to create attribute instance: %s', $e->getMessage()), 0, $e);
}
}
foreach ($reflectionClass->getMethods(ReflectionMethod::IS_PUBLIC) as $method) {
foreach ($method->getAttributes(PreloadUrl::class, ReflectionAttribute::IS_INSTANCEOF) as $attribute) {
try {
/** @var PreloadUrl $preloadAttribute */
$preloadAttribute = $attribute->newInstance();
yield from $this->findAllRoutesForMethod($preloadAttribute, $method);
} catch (Throwable $e) {
throw new RuntimeException(sprintf(
'Unable to create attribute instance: %s',
$e->getMessage()
), 0, $e);
}
}
}
}

/**
* @param array<ReflectionMethod> $methods
* @return iterable<array{alias: string, route: string, params: array<string, mixed>, pathTypeReference: int}>
*/
private function findAllRoutesToPreload(PreloadUrl $preloadAttribute, array $methods): iterable
{
foreach ($methods as $method) {
yield from $this->findAllRoutesForMethod($preloadAttribute, $method);
}
}

/**
* @return iterable<array{alias: string, route: string, params: array<string, mixed>, pathTypeReference: int}>
*/
private function findAllRoutesForMethod(PreloadUrl $preloadAttribute, ReflectionMethod $method): iterable
{
foreach ($method->getAttributes(Route::class, ReflectionAttribute::IS_INSTANCEOF) as $attribute) {
try {
/** @var Route $routeAttribute */
$routeAttribute = $attribute->newInstance();
$routeName = $routeAttribute->getName();
if ($routeName === null) {
continue;
}
yield [
'alias' => $preloadAttribute->alias,
'route' => $routeName,
'params' => $preloadAttribute->params,
'pathTypeReference' => $preloadAttribute->pathTypeReference,
];
} catch (Throwable) {
continue;
}
}
}
}
17 changes: 17 additions & 0 deletions src/CachingStrategy/PreloadUrlsGeneratorInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

declare(strict_types=1);

namespace SpomkyLabs\PwaBundle\CachingStrategy;

use SpomkyLabs\PwaBundle\Dto\Url;

interface PreloadUrlsGeneratorInterface
{
public function getAlias(): string;

/**
* @return iterable<Url|string>
*/
public function generateUrls(): iterable;
}
45 changes: 45 additions & 0 deletions src/CachingStrategy/PreloadUrlsGeneratorManager.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php

declare(strict_types=1);

namespace SpomkyLabs\PwaBundle\CachingStrategy;

use InvalidArgumentException;
use Symfony\Component\DependencyInjection\Attribute\TaggedIterator;
use function array_key_exists;

final class PreloadUrlsGeneratorManager
{
/**
* @var array<string, PreloadUrlsGeneratorInterface>
*/
private array $generators = [];

/**
* @param PreloadUrlsGeneratorInterface[] $generators
*/
public function __construct(
#[TaggedIterator('spomky_labs_pwa.preload_urls_generator')]
iterable $generators
) {
foreach ($generators as $generator) {
$this->add($generator);
}
}

public function add(PreloadUrlsGeneratorInterface $generator, PreloadUrlsGeneratorInterface ...$generators): void
{
$this->generators[$generator->getAlias()] = $generator;
foreach ($generators as $value) {
$this->generators[$generator->getAlias()] = $value;
}
}

public function get(string $alias): PreloadUrlsGeneratorInterface
{
if (! array_key_exists($alias, $this->generators)) {
throw new InvalidArgumentException(sprintf('The generator with alias "%s" does not exist.', $alias));
}
return $this->generators[$alias];
}
}
41 changes: 41 additions & 0 deletions src/CachingStrategy/PreloadUrlsTagGenerator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php

declare(strict_types=1);

namespace SpomkyLabs\PwaBundle\CachingStrategy;

use SpomkyLabs\PwaBundle\Dto\Url;

final readonly class PreloadUrlsTagGenerator implements PreloadUrlsGeneratorInterface
{
/**
* @var array<Url>
*/
private array $urls;

/**
* @param array{route: string, params: array<string, mixed>, pathTypeReference: int}[] $urls
*/
public function __construct(
private string $alias,
array $urls
) {
$this->urls = array_map(
static fn (array $url): Url => Url::create($url['route'], $url['params'], $url['pathTypeReference']),
$urls
);
}

public function getAlias(): string
{
return $this->alias;
}

/**
* @return iterable<Url>
*/
public function generateUrls(): iterable
{
return $this->urls;
}
}
27 changes: 25 additions & 2 deletions src/CachingStrategy/ResourceCaches.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace SpomkyLabs\PwaBundle\CachingStrategy;

use SpomkyLabs\PwaBundle\Dto\ServiceWorker;
use SpomkyLabs\PwaBundle\Dto\Url;
use SpomkyLabs\PwaBundle\Dto\Workbox;
use SpomkyLabs\PwaBundle\MatchCallbackHandler\MatchCallbackHandlerInterface;
use SpomkyLabs\PwaBundle\WorkboxPlugin\BroadcastUpdatePlugin;
Expand All @@ -31,6 +32,7 @@
* @param iterable<MatchCallbackHandlerInterface> $matchCallbackHandlers
*/
public function __construct(
private PreloadUrlsGeneratorManager $preloadUrlsGeneratorManager,
ServiceWorker $serviceWorker,
private SerializerInterface $serializer,
#[TaggedIterator('spomky_labs_pwa.match_callback_handler')]
Expand All @@ -50,11 +52,10 @@ public function getCacheStrategies(): array
{
$strategies = [];
foreach ($this->workbox->resourceCaches as $id => $resourceCache) {
$routes = $this->serializer->serialize($resourceCache->urls, 'json', [
$routes = $this->serializer->serialize($this->getUrls($resourceCache->urls), 'json', [
JsonEncode::OPTIONS => $this->jsonOptions,
]);
$urls = json_decode($routes, true, 512, JSON_THROW_ON_ERROR);

$cacheName = $resourceCache->cacheName ?? sprintf('page-cache-%d', $id);

$plugins = [
Expand Down Expand Up @@ -104,4 +105,26 @@ private function prepareMatchCallback(string $matchCallback): string

return $matchCallback;
}

/**
* @param array<Url> $urls
* @return array<Url|string>
*/
private function getUrls(array $urls): array
{
$result = [];
foreach ($urls as $url) {
if (str_starts_with($url->path, '@')) {
$generator = $this->preloadUrlsGeneratorManager->get(mb_substr($url->path, 1));
$list = $generator->generateUrls();
foreach ($list as $item) {
$result[] = $item;
}
} else {
$result[] = $url;
}
}

return $result;
}
}
Loading

0 comments on commit 3e3ca4a

Please sign in to comment.