From a2549f2aecd07c31a2f1e3ca2c531dc2b5e5fb41 Mon Sep 17 00:00:00 2001 From: Florent Morselli Date: Sun, 7 Apr 2024 16:36:53 +0200 Subject: [PATCH] Implement preload URL generation for caching strategies Revised the caching strategy to generate preload URLs dynamically. A new class, PreloadUrlsGeneratorManager, was added to manage all instances of URL generators. URL generation is now supported in the CacheStrategies service to generate additional or specific URLs for caches. An attribute 'PreloadUrl' was also implemented to attach directly to controllers or methods. Removed depreciated DummyController test. --- composer.json | 2 + phpstan-baseline.neon | 27 +++- rector.php | 5 +- src/Attribute/PreloadUrl.php | 22 +++ src/Attribute/PreloadUrlCompilerPass.php | 131 ++++++++++++++++++ .../PreloadUrlsGeneratorInterface.php | 17 +++ .../PreloadUrlsGeneratorManager.php | 45 ++++++ .../PreloadUrlsTagGenerator.php | 41 ++++++ src/CachingStrategy/ResourceCaches.php | 27 +++- src/Dto/PageCache.php | 43 +----- src/Dto/ResourceCache.php | 50 +++++++ src/Dto/Url.php | 13 ++ src/Resources/config/services.php | 16 +++ src/SpomkyLabsPwaBundle.php | 6 + ...ontroller.php => OtherPagesController.php} | 22 +-- tests/Controller/StaticPagesController.php | 26 ++++ tests/Controller/WidgetController.php | 27 ++++ tests/DummyImageProcessor.php | 4 +- tests/DummyUrlsGenerator.php | 25 ++++ tests/TestFilesystem.php | 4 +- tests/config.php | 23 +-- 21 files changed, 493 insertions(+), 83 deletions(-) create mode 100644 src/Attribute/PreloadUrl.php create mode 100644 src/Attribute/PreloadUrlCompilerPass.php create mode 100644 src/CachingStrategy/PreloadUrlsGeneratorInterface.php create mode 100644 src/CachingStrategy/PreloadUrlsGeneratorManager.php create mode 100644 src/CachingStrategy/PreloadUrlsTagGenerator.php create mode 100644 src/Dto/ResourceCache.php rename tests/Controller/{DummyController.php => OtherPagesController.php} (54%) create mode 100644 tests/Controller/StaticPagesController.php create mode 100644 tests/Controller/WidgetController.php create mode 100644 tests/DummyUrlsGenerator.php diff --git a/composer.json b/composer.json index b92a7f8..60322b3 100644 --- a/composer.json +++ b/composer.json @@ -47,6 +47,7 @@ "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", @@ -54,6 +55,7 @@ "phpstan/phpstan-strict-rules": "^1.0", "phpunit/phpunit": "^10.1", "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", diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index e53359a..82eed52 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -35,6 +35,11 @@ parameters: count: 2 path: src/CachingStrategy/FontCache.php + - + message: "#^Foreach overwrites \\$generator with its value variable\\.$#" + count: 1 + path: src/CachingStrategy/PreloadUrlsGeneratorManager.php + - message: "#^Only iterables can be unpacked, mixed given in argument \\#1\\.$#" count: 1 @@ -231,12 +236,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\\\\|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 @@ -260,6 +260,16 @@ 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: "#^PHPDoc tag @var for property SpomkyLabs\\\\PwaBundle\\\\Dto\\\\ResourceCache\\:\\:\\$cacheableResponseHeaders with type array\\\\|null is not subtype of native type array\\.$#" + 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 @@ -340,6 +350,11 @@ parameters: count: 1 path: src/Dto/Url.php + - + message: "#^Method SpomkyLabs\\\\PwaBundle\\\\Dto\\\\Url\\:\\:create\\(\\) has parameter \\$params with no value type specified in iterable type array\\.$#" + count: 1 + path: src/Dto/Url.php + - message: "#^Class SpomkyLabs\\\\PwaBundle\\\\Dto\\\\Widget has an uninitialized property \\$adaptativeCardTemplate\\. Give it default value or assign it in the constructor\\.$#" count: 1 diff --git a/rector.php b/rector.php index fe8046d..14591a0 100644 --- a/rector.php +++ b/rector.php @@ -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; @@ -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(); diff --git a/src/Attribute/PreloadUrl.php b/src/Attribute/PreloadUrl.php new file mode 100644 index 0000000..9fb68d0 --- /dev/null +++ b/src/Attribute/PreloadUrl.php @@ -0,0 +1,22 @@ + $params + */ + public function __construct( + public string $alias, + public array $params = [], + public int $pathTypeReference = UrlGeneratorInterface::ABSOLUTE_PATH, + ) { + } +} diff --git a/src/Attribute/PreloadUrlCompilerPass.php b/src/Attribute/PreloadUrlCompilerPass.php new file mode 100644 index 0000000..327cb53 --- /dev/null +++ b/src/Attribute/PreloadUrlCompilerPass.php @@ -0,0 +1,131 @@ +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, 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 $reflectionClass + * @return iterable, 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 $methods + * @return iterable, pathTypeReference: int}> + */ + private function findAllRoutesToPreload(PreloadUrl $preloadAttribute, array $methods): iterable + { + foreach ($methods as $method) { + yield from $this->findAllRoutesForMethod($preloadAttribute, $method); + } + } + + /** + * @return iterable, 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; + } + } + } +} diff --git a/src/CachingStrategy/PreloadUrlsGeneratorInterface.php b/src/CachingStrategy/PreloadUrlsGeneratorInterface.php new file mode 100644 index 0000000..dbc3cd1 --- /dev/null +++ b/src/CachingStrategy/PreloadUrlsGeneratorInterface.php @@ -0,0 +1,17 @@ + + */ + public function generateUrls(): iterable; +} diff --git a/src/CachingStrategy/PreloadUrlsGeneratorManager.php b/src/CachingStrategy/PreloadUrlsGeneratorManager.php new file mode 100644 index 0000000..382c75d --- /dev/null +++ b/src/CachingStrategy/PreloadUrlsGeneratorManager.php @@ -0,0 +1,45 @@ + + */ + 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 $generator) { + $this->generators[$generator->getAlias()] = $generator; + } + } + + 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]; + } +} diff --git a/src/CachingStrategy/PreloadUrlsTagGenerator.php b/src/CachingStrategy/PreloadUrlsTagGenerator.php new file mode 100644 index 0000000..9181ff8 --- /dev/null +++ b/src/CachingStrategy/PreloadUrlsTagGenerator.php @@ -0,0 +1,41 @@ + + */ + private array $urls; + + /** + * @param array{route: string, params: array, 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 + */ + public function generateUrls(): iterable + { + return $this->urls; + } +} diff --git a/src/CachingStrategy/ResourceCaches.php b/src/CachingStrategy/ResourceCaches.php index 52831ff..15cb0f5 100644 --- a/src/CachingStrategy/ResourceCaches.php +++ b/src/CachingStrategy/ResourceCaches.php @@ -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; @@ -31,6 +32,7 @@ * @param iterable $matchCallbackHandlers */ public function __construct( + private PreloadUrlsGeneratorManager $preloadUrlsGeneratorManager, ServiceWorker $serviceWorker, private SerializerInterface $serializer, #[TaggedIterator('spomky_labs_pwa.match_callback_handler')] @@ -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 = [ @@ -104,4 +105,26 @@ private function prepareMatchCallback(string $matchCallback): string return $matchCallback; } + + /** + * @param array $urls + * @return array + */ + 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; + } } diff --git a/src/Dto/PageCache.php b/src/Dto/PageCache.php index 3eb1245..069010f 100644 --- a/src/Dto/PageCache.php +++ b/src/Dto/PageCache.php @@ -4,44 +4,9 @@ namespace SpomkyLabs\PwaBundle\Dto; -use Symfony\Component\Serializer\Attribute\SerializedName; - -final class PageCache extends Cache +/** + * @deprecated since 1.2.0 and will be removed in 2.0.0. Use ResourceCache instead. + */ +final class PageCache extends ResourceCache { - #[SerializedName('match_callback')] - public string $matchCallback; - - #[SerializedName('network_timeout')] - public int $networkTimeout = 3; - - public string $strategy = 'NetworkFirst'; - - public bool $broadcast = false; - - #[SerializedName('range_requests')] - public bool $rangeRequests = false; - - /** - * @var int[] - */ - #[SerializedName('cacheable_response_statuses')] - public array $cacheableResponseStatuses = [0, 200]; - - /** - * @var null|array - */ - #[SerializedName('cacheable_response_headers')] - public array $cacheableResponseHeaders = []; - - /** - * @var array - */ - #[SerializedName('broadcast_headers')] - public array $broadcastHeaders = ['Content-Type', 'ETag', 'Last-Modified']; - - /** - * @var array - */ - #[SerializedName('preload_urls')] - public array $urls = []; } diff --git a/src/Dto/ResourceCache.php b/src/Dto/ResourceCache.php new file mode 100644 index 0000000..bfa376f --- /dev/null +++ b/src/Dto/ResourceCache.php @@ -0,0 +1,50 @@ + + */ + #[SerializedName('cacheable_response_headers')] + public array $cacheableResponseHeaders = []; + + /** + * @var array + */ + #[SerializedName('broadcast_headers')] + public array $broadcastHeaders = ['Content-Type', 'ETag', 'Last-Modified']; + + /** + * @var array + */ + #[SerializedName('preload_urls')] + public array $urls = []; +} diff --git a/src/Dto/Url.php b/src/Dto/Url.php index 0e9110e..5b0937f 100644 --- a/src/Dto/Url.php +++ b/src/Dto/Url.php @@ -18,4 +18,17 @@ final class Url * @var array */ public array $params = []; + + public static function create( + string $path, + array $params = [], + int $pathTypeReference = UrlGeneratorInterface::ABSOLUTE_PATH + ): self { + $url = new self(); + $url->path = $path; + $url->pathTypeReference = $pathTypeReference; + $url->params = $params; + + return $url; + } } diff --git a/src/Resources/config/services.php b/src/Resources/config/services.php index f446019..dd2ea2c 100644 --- a/src/Resources/config/services.php +++ b/src/Resources/config/services.php @@ -4,6 +4,8 @@ use Facebook\WebDriver\WebDriverDimension; use SpomkyLabs\PwaBundle\CachingStrategy\HasCacheStrategiesInterface; +use SpomkyLabs\PwaBundle\CachingStrategy\PreloadUrlsGeneratorManager; +use SpomkyLabs\PwaBundle\CachingStrategy\PreloadUrlsTagGenerator; use SpomkyLabs\PwaBundle\Command\CreateIconsCommand; use SpomkyLabs\PwaBundle\Command\CreateScreenshotCommand; use SpomkyLabs\PwaBundle\Command\ListCacheStrategiesCommand; @@ -26,6 +28,8 @@ use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; use Symfony\Component\Mime\MimeTypes; use Symfony\Component\Panther\Client; +use Symfony\Component\Routing\Generator\UrlGeneratorInterface; +use function Symfony\Component\DependencyInjection\Loader\Configurator\abstract_arg; use function Symfony\Component\DependencyInjection\Loader\Configurator\param; use function Symfony\Component\DependencyInjection\Loader\Configurator\service; @@ -126,6 +130,18 @@ ; $container->load('SpomkyLabs\\PwaBundle\\MatchCallbackHandler\\', '../../MatchCallbackHandler/*'); + $container->set(PreloadUrlsGeneratorManager::class); + $container->instanceof(UrlGeneratorInterface::class) + ->tag('spomky_labs_pwa.preload_urls_generator') + ; + $container->set(PreloadUrlsTagGenerator::class) + ->abstract() + ->args([ + '$alias' => abstract_arg('alias'), + '$urls' => abstract_arg('urls'), + ]) + ; + if ($configurator->env() !== 'prod') { $container->set(PwaCollector::class) ->tag('data_collector', [ diff --git a/src/SpomkyLabsPwaBundle.php b/src/SpomkyLabsPwaBundle.php index f698c7f..77e87f4 100644 --- a/src/SpomkyLabsPwaBundle.php +++ b/src/SpomkyLabsPwaBundle.php @@ -4,6 +4,7 @@ namespace SpomkyLabs\PwaBundle; +use SpomkyLabs\PwaBundle\Attribute\PreloadUrlCompilerPass; use SpomkyLabs\PwaBundle\ImageProcessor\ImageProcessorInterface; use SpomkyLabs\PwaBundle\Subscriber\PwaDevServerSubscriber; use Symfony\Component\Config\Definition\Configurator\DefinitionConfigurator; @@ -21,6 +22,11 @@ public function configure(DefinitionConfigurator $definition): void $definition->import('Resources/config/definition/*.php'); } + public function build(ContainerBuilder $container): void + { + $container->addCompilerPass(new PreloadUrlCompilerPass()); + } + public function loadExtension(array $config, ContainerConfigurator $container, ContainerBuilder $builder): void { $container->import('Resources/config/services.php'); diff --git a/tests/Controller/DummyController.php b/tests/Controller/OtherPagesController.php similarity index 54% rename from tests/Controller/DummyController.php rename to tests/Controller/OtherPagesController.php index 032af58..4fd4883 100644 --- a/tests/Controller/DummyController.php +++ b/tests/Controller/OtherPagesController.php @@ -10,18 +10,8 @@ /** * @internal */ -final class DummyController extends AbstractController +final class OtherPagesController extends AbstractController { - #[Route('/privacy-policy', name: 'privacy_policy')] - public function privacyPolicy(string $param1): void - { - } - - #[Route('/terms-of-service', name: 'terms_of_service')] - public function tos(string $param1): void - { - } - #[Route('/audio-file-handler/{param1}', name: 'audio_file_handler')] public function dummy1(string $param1): void { @@ -36,14 +26,4 @@ public function dummy2(string $param1, string $param2): void public function agenda(string $date): void { } - - #[Route('/widget/template', name: 'app_widget_template')] - public function widgetTemplate(): void - { - } - - #[Route('/widget/data', name: 'app_widget_data')] - public function widgetData(): void - { - } } diff --git a/tests/Controller/StaticPagesController.php b/tests/Controller/StaticPagesController.php new file mode 100644 index 0000000..44a7ba5 --- /dev/null +++ b/tests/Controller/StaticPagesController.php @@ -0,0 +1,26 @@ +services() - ->set(DummyImageProcessor::class); - $container->services() - ->set('asset_mapper.local_public_assets_filesystem', TestFilesystem::class) + $services = $container + ->services() + ->defaults() + ->autowire() ->autoconfigure() - ->autowire(); - - $container->services() - ->load('SpomkyLabs\\PwaBundle\\Tests\\Controller\\', __DIR__ . '/Controller/') - ->tag('controller.service_arguments') + ; + $services->set(DummyImageProcessor::class); + $services->set('asset_mapper.local_public_assets_filesystem', TestFilesystem::class); + $services->load('SpomkyLabs\\PwaBundle\\Tests\\Controller\\', __DIR__ . '/Controller/'); + $services + ->set(DummyUrlsGenerator::class) + ->tag('spomky_labs_pwa.preload_urls_generator') ; $container->extension('framework', [ @@ -232,7 +235,7 @@ 'strategy' => 'StaleWhileRevalidate', 'cache_name' => 'page-cache', 'broadcast' => true, - 'preload_urls' => ['privacy_policy', 'terms_of_service'], + 'preload_urls' => ['privacy_policy', 'terms_of_service', '@static-pages', '@widgets'], ], ], 'offline_fallback' => [