diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php index 2caedb4e21b74..82171117ee739 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php @@ -696,6 +696,10 @@ private function addAssetsSection(ArrayNodeDefinition $rootNode, callable $enabl ->{$enableIfStandalone('symfony/asset', Package::class)}() ->fixXmlConfig('base_url') ->children() + ->booleanNode('strict_mode') + ->info('Throw an exception if an entry is missing from the manifest.json') + ->defaultFalse() + ->end() ->scalarNode('version_strategy')->defaultNull()->end() ->scalarNode('version')->defaultNull()->end() ->scalarNode('version_format')->defaultValue('%%s?%%s')->end() @@ -733,6 +737,10 @@ private function addAssetsSection(ArrayNodeDefinition $rootNode, callable $enabl ->prototype('array') ->fixXmlConfig('base_url') ->children() + ->booleanNode('strict_mode') + ->info('Throw an exception if an entry is missing from the manifest.json') + ->defaultFalse() + ->end() ->scalarNode('version_strategy')->defaultNull()->end() ->scalarNode('version') ->beforeNormalization() diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index 82267d24a87b4..3a5aa40ea7b09 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -1141,7 +1141,7 @@ private function registerAssetsConfiguration(array $config, ContainerBuilder $co if ($config['version_strategy']) { $defaultVersion = new Reference($config['version_strategy']); } else { - $defaultVersion = $this->createVersion($container, $config['version'], $config['version_format'], $config['json_manifest_path'], '_default'); + $defaultVersion = $this->createVersion($container, $config['version'], $config['version_format'], $config['json_manifest_path'], '_default', $config['strict_mode']); } $defaultPackage = $this->createPackageDefinition($config['base_path'], $config['base_urls'], $defaultVersion); @@ -1157,7 +1157,7 @@ private function registerAssetsConfiguration(array $config, ContainerBuilder $co // let format fallback to main version_format $format = $package['version_format'] ?: $config['version_format']; $version = $package['version'] ?? null; - $version = $this->createVersion($container, $version, $format, $package['json_manifest_path'], $name); + $version = $this->createVersion($container, $version, $format, $package['json_manifest_path'], $name, $package['strict_mode']); } $packageDefinition = $this->createPackageDefinition($package['base_path'], $package['base_urls'], $version) @@ -1186,7 +1186,7 @@ private function createPackageDefinition(?string $basePath, array $baseUrls, Ref return $package; } - private function createVersion(ContainerBuilder $container, ?string $version, ?string $format, ?string $jsonManifestPath, string $name): Reference + private function createVersion(ContainerBuilder $container, ?string $version, ?string $format, ?string $jsonManifestPath, string $name, bool $strictMode): Reference { // Configuration prevents $version and $jsonManifestPath from being set if (null !== $version) { @@ -1203,6 +1203,7 @@ private function createVersion(ContainerBuilder $container, ?string $version, ?s if (null !== $jsonManifestPath) { $def = new ChildDefinition('assets.json_manifest_version_strategy'); $def->replaceArgument(0, $jsonManifestPath); + $def->replaceArgument(2, $strictMode); $container->setDefinition('assets._version_'.$name, $def); return new Reference('assets._version_'.$name); diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/assets.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/assets.php index a6f278743a75f..1e250aab4dceb 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/assets.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/assets.php @@ -80,6 +80,7 @@ ->args([ abstract_arg('manifest path'), service('http_client')->nullOnInvalid(), + false, ]) ->set('assets.remote_json_manifest_version_strategy', RemoteJsonManifestVersionStrategy::class) diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd b/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd index edcaca3a90018..5082c3356a673 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd @@ -155,6 +155,7 @@ + @@ -168,6 +169,7 @@ + diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php index 24220cc5e7629..9d1f5ea421d37 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php @@ -86,6 +86,7 @@ public function testAssetsCanBeEnabled() 'base_urls' => [], 'packages' => [], 'json_manifest_path' => null, + 'strict_mode' => false, ]; $this->assertEquals($defaultConfig, $config['assets']); @@ -489,6 +490,7 @@ protected static function getBundleDefaultConfig() 'base_urls' => [], 'packages' => [], 'json_manifest_path' => null, + 'strict_mode' => false, ], 'cache' => [ 'pools' => [], diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/assets.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/assets.php index ab16a52e21e9b..f26621001c9ec 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/assets.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/assets.php @@ -36,6 +36,10 @@ 'env_manifest' => [ 'json_manifest_path' => '%env(env_manifest)%', ], + 'strict_manifest_strategy' => [ + 'json_manifest_path' => '/path/to/manifest.json', + 'strict_mode' => true, + ], ], ], ]); diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/assets.xml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/assets.xml index ae0e0e099bc93..dadee4529d8b5 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/assets.xml +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/assets.xml @@ -25,6 +25,7 @@ + diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/assets.yml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/assets.yml index ab9eb1b610ce8..cfd4f07b04346 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/assets.yml +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/assets.yml @@ -25,6 +25,9 @@ framework: json_manifest_path: '%var_json_manifest_path%' env_manifest: json_manifest_path: '%env(env_manifest)%' + strict_manifest_strategy: + json_manifest_path: '/path/to/manifest.json' + strict_mode: true parameters: var_json_manifest_path: 'https://cdn.example.com/manifest.json' diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTest.php index b764b2b779a71..e20736f7b9d69 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTest.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTest.php @@ -632,7 +632,7 @@ public function testAssets() // packages $packageTags = $container->findTaggedServiceIds('assets.package'); - $this->assertCount(9, $packageTags); + $this->assertCount(10, $packageTags); $packages = []; foreach ($packageTags as $serviceId => $tagAttributes) { @@ -658,6 +658,7 @@ public function testAssets() $versionStrategy = $container->getDefinition((string) $package->getArgument(1)); $this->assertEquals('assets.json_manifest_version_strategy', $versionStrategy->getParent()); $this->assertEquals('/path/to/manifest.json', $versionStrategy->getArgument(0)); + $this->assertFalse($versionStrategy->getArgument(2)); $package = $container->getDefinition($packages['remote_manifest']); $versionStrategy = $container->getDefinition($package->getArgument(1)); @@ -668,11 +669,19 @@ public function testAssets() $versionStrategy = $container->getDefinition($package->getArgument(1)); $this->assertSame('assets.json_manifest_version_strategy', $versionStrategy->getParent()); $this->assertSame('https://cdn.example.com/manifest.json', $versionStrategy->getArgument(0)); + $this->assertFalse($versionStrategy->getArgument(2)); $package = $container->getDefinition($packages['env_manifest']); $versionStrategy = $container->getDefinition($package->getArgument(1)); $this->assertSame('assets.json_manifest_version_strategy', $versionStrategy->getParent()); $this->assertStringMatchesFormat('env_%s', $versionStrategy->getArgument(0)); + $this->assertFalse($versionStrategy->getArgument(2)); + + $package = $container->getDefinition((string) $packages['strict_manifest_strategy']); + $versionStrategy = $container->getDefinition((string) $package->getArgument(1)); + $this->assertEquals('assets.json_manifest_version_strategy', $versionStrategy->getParent()); + $this->assertEquals('/path/to/manifest.json', $versionStrategy->getArgument(0)); + $this->assertTrue($versionStrategy->getArgument(2)); } public function testAssetsDefaultVersionStrategyAsService() diff --git a/src/Symfony/Component/Asset/Exception/AssetNotFoundException.php b/src/Symfony/Component/Asset/Exception/AssetNotFoundException.php new file mode 100644 index 0000000000000..954294bdc1fba --- /dev/null +++ b/src/Symfony/Component/Asset/Exception/AssetNotFoundException.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Asset\Exception; + +/** + * Represents an asset not found in a manifest. + */ +class AssetNotFoundException extends RuntimeException +{ + private $alternatives; + + /** + * @param string $message Exception message to throw + * @param array $alternatives List of similar defined names + * @param int $code Exception code + * @param \Throwable $previous Previous exception used for the exception chaining + */ + public function __construct(string $message, array $alternatives = [], int $code = 0, \Throwable $previous = null) + { + parent::__construct($message, $code, $previous); + + $this->alternatives = $alternatives; + } + + /** + * @return array A list of similar defined names + */ + public function getAlternatives(): array + { + return $this->alternatives; + } +} diff --git a/src/Symfony/Component/Asset/Exception/RuntimeException.php b/src/Symfony/Component/Asset/Exception/RuntimeException.php new file mode 100644 index 0000000000000..13dc58b4d3f74 --- /dev/null +++ b/src/Symfony/Component/Asset/Exception/RuntimeException.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Asset\Exception; + +/** + * Base RuntimeException for the Asset component. + */ +class RuntimeException extends \RuntimeException implements ExceptionInterface +{ +} diff --git a/src/Symfony/Component/Asset/Tests/VersionStrategy/JsonManifestVersionStrategyTest.php b/src/Symfony/Component/Asset/Tests/VersionStrategy/JsonManifestVersionStrategyTest.php index 57f1618dda30c..d3748bb7532bc 100644 --- a/src/Symfony/Component/Asset/Tests/VersionStrategy/JsonManifestVersionStrategyTest.php +++ b/src/Symfony/Component/Asset/Tests/VersionStrategy/JsonManifestVersionStrategyTest.php @@ -12,6 +12,8 @@ namespace Symfony\Component\Asset\Tests\VersionStrategy; use PHPUnit\Framework\TestCase; +use Symfony\Component\Asset\Exception\AssetNotFoundException; +use Symfony\Component\Asset\Exception\RuntimeException; use Symfony\Component\Asset\VersionStrategy\JsonManifestVersionStrategy; use Symfony\Component\HttpClient\MockHttpClient; use Symfony\Component\HttpClient\Response\MockResponse; @@ -19,7 +21,7 @@ class JsonManifestVersionStrategyTest extends TestCase { /** - * @dataProvider ProvideValidStrategies + * @dataProvider provideValidStrategies */ public function testGetVersion(JsonManifestVersionStrategy $strategy) { @@ -27,7 +29,7 @@ public function testGetVersion(JsonManifestVersionStrategy $strategy) } /** - * @dataProvider ProvideValidStrategies + * @dataProvider provideValidStrategies */ public function testApplyVersion(JsonManifestVersionStrategy $strategy) { @@ -35,7 +37,7 @@ public function testApplyVersion(JsonManifestVersionStrategy $strategy) } /** - * @dataProvider ProvideValidStrategies + * @dataProvider provideValidStrategies */ public function testApplyVersionWhenKeyDoesNotExistInManifest(JsonManifestVersionStrategy $strategy) { @@ -43,20 +45,31 @@ public function testApplyVersionWhenKeyDoesNotExistInManifest(JsonManifestVersio } /** - * @dataProvider ProvideMissingStrategies + * @dataProvider provideStrictStrategies + */ + public function testStrictExceptionWhenKeyDoesNotExistInManifest(JsonManifestVersionStrategy $strategy, $path, $message) + { + $this->expectException(AssetNotFoundException::class); + $this->expectExceptionMessageMatches($message); + + $strategy->getVersion($path); + } + + /** + * @dataProvider provideMissingStrategies */ public function testMissingManifestFileThrowsException(JsonManifestVersionStrategy $strategy) { - $this->expectException(\RuntimeException::class); + $this->expectException(RuntimeException::class); $strategy->getVersion('main.js'); } /** - * @dataProvider ProvideInvalidStrategies + * @dataProvider provideInvalidStrategies */ public function testManifestFileWithBadJSONThrowsException(JsonManifestVersionStrategy $strategy) { - $this->expectException(\RuntimeException::class); + $this->expectException(RuntimeException::class); $this->expectExceptionMessage('Error parsing JSON'); $strategy->getVersion('main.js'); } @@ -100,4 +113,21 @@ public function provideStrategies(string $manifestPath) yield [new JsonManifestVersionStrategy(__DIR__.'/../fixtures/'.$manifestPath)]; } + + public function provideStrictStrategies() + { + $strategy = new JsonManifestVersionStrategy(__DIR__.'/../fixtures/manifest-valid.json', null, true); + + yield [ + $strategy, + 'css/styles.555def.css', + '~Asset "css/styles.555def.css" not found in manifest "(.*)/manifest-valid.json"\. Did you mean one of these\? "css/styles.css", "css/style.css".~', + ]; + + yield [ + $strategy, + 'img/avatar.png', + '~Asset "img/avatar.png" not found in manifest "(.*)/manifest-valid.json"\.~', + ]; + } } diff --git a/src/Symfony/Component/Asset/Tests/fixtures/manifest-valid.json b/src/Symfony/Component/Asset/Tests/fixtures/manifest-valid.json index 546a0066d31ee..9bdbcc7b351ab 100644 --- a/src/Symfony/Component/Asset/Tests/fixtures/manifest-valid.json +++ b/src/Symfony/Component/Asset/Tests/fixtures/manifest-valid.json @@ -1,4 +1,6 @@ { "main.js": "main.123abc.js", - "css/styles.css": "css/styles.555def.css" + "css/styles.css": "css/styles.555def.css", + "css/style.css": "css/style.abcdef.css", + "main/home.css": "main/home.css" } diff --git a/src/Symfony/Component/Asset/VersionStrategy/JsonManifestVersionStrategy.php b/src/Symfony/Component/Asset/VersionStrategy/JsonManifestVersionStrategy.php index 256bc21963720..3840fea59f2e4 100644 --- a/src/Symfony/Component/Asset/VersionStrategy/JsonManifestVersionStrategy.php +++ b/src/Symfony/Component/Asset/VersionStrategy/JsonManifestVersionStrategy.php @@ -11,6 +11,10 @@ namespace Symfony\Component\Asset\VersionStrategy; +use Symfony\Component\Asset\Exception\AssetNotFoundException; +use Symfony\Component\Asset\Exception\LogicException; +use Symfony\Component\Asset\Exception\RuntimeException; +use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface; use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; @@ -30,17 +34,20 @@ class JsonManifestVersionStrategy implements VersionStrategyInterface private $manifestPath; private $manifestData; private $httpClient; + private $strictMode; /** * @param string $manifestPath Absolute path to the manifest file + * @param bool $strictMode Throws an exception for unknown paths */ - public function __construct(string $manifestPath, HttpClientInterface $httpClient = null) + public function __construct(string $manifestPath, HttpClientInterface $httpClient = null, $strictMode = false) { $this->manifestPath = $manifestPath; $this->httpClient = $httpClient; + $this->strictMode = $strictMode; if (null === $this->httpClient && ($scheme = parse_url($this->manifestPath, \PHP_URL_SCHEME)) && 0 === strpos($scheme, 'http')) { - throw new \LogicException(sprintf('The "%s" class needs an HTTP client to use a remote manifest. Try running "composer require symfony/http-client".', self::class)); + throw new LogicException(sprintf('The "%s" class needs an HTTP client to use a remote manifest. Try running "composer require symfony/http-client".', self::class)); } } @@ -68,20 +75,58 @@ private function getManifestPath(string $path): ?string 'headers' => ['accept' => 'application/json'], ])->toArray(); } catch (DecodingExceptionInterface $e) { - throw new \RuntimeException(sprintf('Error parsing JSON from asset manifest URL "%s".', $this->manifestPath), 0, $e); + throw new RuntimeException(sprintf('Error parsing JSON from asset manifest URL "%s".', $this->manifestPath), 0, $e); + } catch (ClientExceptionInterface $e) { + throw new RuntimeException(sprintf('Error loading JSON from asset manifest URL "%s".', $this->manifestPath), 0, $e); } } else { if (!is_file($this->manifestPath)) { - throw new \RuntimeException(sprintf('Asset manifest file "%s" does not exist.', $this->manifestPath)); + throw new RuntimeException(sprintf('Asset manifest file "%s" does not exist.', $this->manifestPath)); } $this->manifestData = json_decode(file_get_contents($this->manifestPath), true); if (0 < json_last_error()) { - throw new \RuntimeException(sprintf('Error parsing JSON from asset manifest file "%s": ', $this->manifestPath).json_last_error_msg()); + throw new RuntimeException(sprintf('Error parsing JSON from asset manifest file "%s": ', $this->manifestPath).json_last_error_msg()); } } } - return $this->manifestData[$path] ?? null; + if (isset($this->manifestData[$path])) { + return $this->manifestData[$path]; + } + + if ($this->strictMode) { + $message = sprintf('Asset "%s" not found in manifest "%s".', $path, $this->manifestPath); + $alternatives = $this->findAlternatives($path, $this->manifestData); + if (\count($alternatives) > 0) { + $message .= sprintf(' Did you mean one of these? "%s".', implode('", "', $alternatives)); + } + + throw new AssetNotFoundException($message, $alternatives); + } + + return null; + } + + private function findAlternatives(string $path, array $manifestData): array + { + $path = strtolower($path); + $alternatives = []; + + foreach ($manifestData as $key => $value) { + $lev = levenshtein($path, strtolower($key)); + if ($lev <= \strlen($path) / 3 || false !== stripos($key, $path)) { + $alternatives[$key] = isset($alternatives[$key]) ? min($lev, $alternatives[$key]) : $lev; + } + + $lev = levenshtein($path, strtolower($value)); + if ($lev <= \strlen($path) / 3 || false !== stripos($key, $path)) { + $alternatives[$key] = isset($alternatives[$key]) ? min($lev, $alternatives[$key]) : $lev; + } + } + + asort($alternatives); + + return array_keys($alternatives); } }