From 0316f8ab39ac8d68b564afe39aa77a39f8cc7efe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Fr=C3=A9mont?= Date: Fri, 14 Mar 2025 11:04:41 +0100 Subject: [PATCH 1/2] Add PHP file resource extractor --- .../Extractor/AbstractResourceExtractor.php | 120 ++++++++++++++++++ .../Extractor/PhpFileResourceExtractor.php | 53 ++++++++ .../Extractor/ResourceExtractorInterface.php | 29 +++++ .../PhpFileResourceExtractorTest.php | 45 +++++++ .../Extractor/php/invalid_php_file.php | 16 +++ .../Metadata/Extractor/php/valid_php_file.php | 19 +++ 6 files changed, 282 insertions(+) create mode 100644 src/Component/src/Metadata/Extractor/AbstractResourceExtractor.php create mode 100644 src/Component/src/Metadata/Extractor/PhpFileResourceExtractor.php create mode 100644 src/Component/src/Metadata/Extractor/ResourceExtractorInterface.php create mode 100644 src/Component/tests/Metadata/Extractor/PhpFileResourceExtractorTest.php create mode 100644 src/Component/tests/Metadata/Extractor/php/invalid_php_file.php create mode 100644 src/Component/tests/Metadata/Extractor/php/valid_php_file.php diff --git a/src/Component/src/Metadata/Extractor/AbstractResourceExtractor.php b/src/Component/src/Metadata/Extractor/AbstractResourceExtractor.php new file mode 100644 index 000000000..dc822ba4c --- /dev/null +++ b/src/Component/src/Metadata/Extractor/AbstractResourceExtractor.php @@ -0,0 +1,120 @@ +resources) { + return $this->resources; + } + + $this->resources = []; + foreach ($this->paths as $path) { + $this->extractPath($path); + } + + return $this->resources; + } + + /** + * Extracts metadata from a given path. + */ + abstract protected function extractPath(string $path): void; + + /** + * Recursively replaces placeholders with the service container parameters. + * + * @see https://github.com/symfony/symfony/blob/6fec32c/src/Symfony/Bundle/FrameworkBundle/Routing/Router.php + * + * @param mixed $value The source which might contain "%placeholders%" + * + * @throws \RuntimeException When a container value is not a string or a numeric value + * + * @return mixed The source with the placeholders replaced by the container + * parameters. Arrays are resolved recursively. + */ + protected function resolve(mixed $value): mixed + { + if (null === $this->container) { + return $value; + } + + if (\is_array($value)) { + foreach ($value as $key => $val) { + $value[$key] = $this->resolve($val); + } + + return $value; + } + + if (!\is_string($value)) { + return $value; + } + + $escapedValue = preg_replace_callback('/%%|%([^%\s]++)%/', function ($match) use ($value) { + $parameter = $match[1] ?? null; + + // skip %% + if (!isset($parameter)) { + return '%%'; + } + + if (preg_match('/^env\(\w+\)$/', $parameter)) { + throw new \RuntimeException(\sprintf('Using "%%%s%%" is not allowed in routing configuration.', $parameter)); + } + + if (\array_key_exists($parameter, $this->collectedParameters)) { + return $this->collectedParameters[$parameter]; + } + + if ($this->container instanceof SymfonyContainerInterface) { + $resolved = $this->container->getParameter($parameter); + } else { + $resolved = $this->container->get($parameter); + } + + if (\is_string($resolved) || is_numeric($resolved)) { + $this->collectedParameters[$parameter] = $resolved; + + return (string) $resolved; + } + + throw new \RuntimeException(\sprintf('The container parameter "%s", used in the resource configuration value "%s", must be a string or numeric, but it is of type %s.', $parameter, $value, \gettype($resolved))); + }, $value); + + return str_replace('%%', '%', $escapedValue); + } +} diff --git a/src/Component/src/Metadata/Extractor/PhpFileResourceExtractor.php b/src/Component/src/Metadata/Extractor/PhpFileResourceExtractor.php new file mode 100644 index 000000000..cc369044c --- /dev/null +++ b/src/Component/src/Metadata/Extractor/PhpFileResourceExtractor.php @@ -0,0 +1,53 @@ +getPHPFileClosure($path)(); + + if (!$resource instanceof ResourceMetadata) { + return; + } + + $resourceReflection = new \ReflectionClass($resource); + + foreach ($resourceReflection->getProperties() as $property) { + $property->setAccessible(true); + $resolvedValue = $this->resolve($property->getValue($resource)); + $property->setValue($resource, $resolvedValue); + } + + $this->resources = [$resource]; + } + + /** + * Scope isolated include. + * + * Prevents access to $this/self from included files. + */ + private function getPHPFileClosure(string $filePath): \Closure + { + return \Closure::bind(function () use ($filePath): mixed { + return require $filePath; + }, null, null); + } +} diff --git a/src/Component/src/Metadata/Extractor/ResourceExtractorInterface.php b/src/Component/src/Metadata/Extractor/ResourceExtractorInterface.php new file mode 100644 index 000000000..811385d94 --- /dev/null +++ b/src/Component/src/Metadata/Extractor/ResourceExtractorInterface.php @@ -0,0 +1,29 @@ +createPullRequestResourceMetadata(); + + $this->assertEquals([$expectedResource], $extractor->getResources()); + } + + public function testItExcludesResourcesFromPhpFileThatDoesNotReturnResourceMetadata(): void + { + $extractor = new PhpFileResourceExtractor([__DIR__ . '/php/invalid_php_file.php']); + + $this->assertEquals([], $extractor->getResources()); + } + + private function createPullRequestResourceMetadata(): ResourceMetadata + { + return (new ResourceMetadata()) + ->withClass(PullRequest::class) + ; + } +} diff --git a/src/Component/tests/Metadata/Extractor/php/invalid_php_file.php b/src/Component/tests/Metadata/Extractor/php/invalid_php_file.php new file mode 100644 index 000000000..404be7c86 --- /dev/null +++ b/src/Component/tests/Metadata/Extractor/php/invalid_php_file.php @@ -0,0 +1,16 @@ +withClass(PullRequest::class) +; From b389d5cf6bffc46112d5d9995d27542420f1da9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Fr=C3=A9mont?= Date: Fri, 14 Mar 2025 11:44:34 +0100 Subject: [PATCH 2/2] Register metadata configuration --- .../DependencyInjection/Configuration.php | 3 ++ .../SyliusResourceExtension.php | 38 ++++++++++++++++++ .../config/services/metadata/extractor.xml | 23 +++++++++++ .../Application/config/sylius/resources.yaml | 2 + .../sylius/resources/empty_php_file.php | 12 ++++++ .../Configuration/ConfigurationTest.php | 40 ++++++++++++++++++- .../SyliusResourceExtensionTest.php | 25 +++++++++++- .../php/empty_php_file.php | 12 ++++++ 8 files changed, 152 insertions(+), 3 deletions(-) create mode 100644 src/Bundle/Resources/config/services/metadata/extractor.xml create mode 100644 tests/Application/config/sylius/resources/empty_php_file.php create mode 100644 tests/Bundle/DependencyInjection/php/empty_php_file.php diff --git a/src/Bundle/DependencyInjection/Configuration.php b/src/Bundle/DependencyInjection/Configuration.php index 6de235407..7da7477d5 100644 --- a/src/Bundle/DependencyInjection/Configuration.php +++ b/src/Bundle/DependencyInjection/Configuration.php @@ -39,6 +39,9 @@ public function getConfigTreeBuilder(): TreeBuilder ->arrayNode('mapping') ->addDefaultsIfNotSet() ->children() + ->arrayNode('imports') + ->prototype('scalar')->end() + ->end() ->arrayNode('paths') ->prototype('scalar')->end() ->end() diff --git a/src/Bundle/DependencyInjection/SyliusResourceExtension.php b/src/Bundle/DependencyInjection/SyliusResourceExtension.php index ba3c013c6..ac90d46af 100644 --- a/src/Bundle/DependencyInjection/SyliusResourceExtension.php +++ b/src/Bundle/DependencyInjection/SyliusResourceExtension.php @@ -33,11 +33,14 @@ use Sylius\Resource\Twig\Context\Factory\ContextFactoryInterface; use Symfony\Component\Config\FileLocator; use Symfony\Component\Config\Loader\LoaderInterface; +use Symfony\Component\Config\Resource\DirectoryResource; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException; +use Symfony\Component\DependencyInjection\Exception\RuntimeException; use Symfony\Component\DependencyInjection\Extension\Extension; use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface; use Symfony\Component\DependencyInjection\Loader\XmlFileLoader; +use Symfony\Component\Finder\Finder; use function Symfony\Component\String\u; final class SyliusResourceExtension extends Extension implements PrependExtensionInterface @@ -66,6 +69,7 @@ public function load(array $configs, ContainerBuilder $container): void $container->setParameter('sylius.resource.settings', $config['settings']); $container->setAlias('sylius.resource_controller.authorization_checker', $config['authorization_checker']); + $this->registerMetadataConfiguration($container, $config); $this->autoRegisterResources($config, $container); $this->loadPersistence($config['drivers'], $config['resources'], $loader, $container); @@ -320,4 +324,38 @@ private function loadResources(array $loadedResources, ContainerBuilder $contain } } } + + private function registerMetadataConfiguration(ContainerBuilder $container, array $config): void + { + $resources = $this->getResourcesToWatch($container, $config); + + $container->getDefinition('sylius.metadata.resource_extractor.php_file')->replaceArgument(0, $resources); + } + + private function getResourcesToWatch(ContainerBuilder $container, array $config): array + { + $resources = []; + + foreach ($config['mapping']['imports'] ?? [] as $path) { + if (is_dir($path)) { + foreach (Finder::create()->followLinks()->files()->in($path)->name('/\.php$/')->sortByName() as $file) { + $resources[] = $file->getRealPath(); + } + + $container->addResource(new DirectoryResource($path, '/\.php$/')); + + continue; + } + + if ($container->fileExists($path, false)) { + $resources[] = $path; + + continue; + } + + throw new RuntimeException(\sprintf('Could not open file or directory "%s".', $path)); + } + + return $resources; + } } diff --git a/src/Bundle/Resources/config/services/metadata/extractor.xml b/src/Bundle/Resources/config/services/metadata/extractor.xml new file mode 100644 index 000000000..9487103ed --- /dev/null +++ b/src/Bundle/Resources/config/services/metadata/extractor.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + diff --git a/tests/Application/config/sylius/resources.yaml b/tests/Application/config/sylius/resources.yaml index e4f291993..975f82c70 100644 --- a/tests/Application/config/sylius/resources.yaml +++ b/tests/Application/config/sylius/resources.yaml @@ -1,5 +1,7 @@ sylius_resource: mapping: + imports: + - '%kernel.project_dir%/config/sylius/resources' paths: - '%kernel.project_dir%/src/BoardGameBlog/Infrastructure/Sylius/Resource' - '%kernel.project_dir%/src/Subscription/Entity' diff --git a/tests/Application/config/sylius/resources/empty_php_file.php b/tests/Application/config/sylius/resources/empty_php_file.php new file mode 100644 index 000000000..7404988e8 --- /dev/null +++ b/tests/Application/config/sylius/resources/empty_php_file.php @@ -0,0 +1,12 @@ + [], ], ], - 'mapping', + 'mapping.paths', ); } @@ -94,7 +94,43 @@ public function its_mapping_paths_can_be_customized(): void ], ], ], - 'mapping', + 'mapping.paths', + ); + } + + /** @test */ + public function it_has_no_default_mapping_imports(): void + { + $this->assertProcessedConfigurationEquals( + [ + [], + ], + [ + 'mapping' => [ + 'imports' => [], + ], + ], + 'mapping.imports', + ); + } + + /** @test */ + public function its_mapping_imports_can_be_customized(): void + { + $this->assertProcessedConfigurationEquals( + [ + ['mapping' => [ + 'imports' => ['path/to/resources'], + ]], + ], + [ + 'mapping' => [ + 'imports' => [ + 'path/to/resources', + ], + ], + ], + 'mapping.imports', ); } diff --git a/tests/Bundle/DependencyInjection/SyliusResourceExtensionTest.php b/tests/Bundle/DependencyInjection/SyliusResourceExtensionTest.php index 11a4e2c50..e37780b25 100644 --- a/tests/Bundle/DependencyInjection/SyliusResourceExtensionTest.php +++ b/tests/Bundle/DependencyInjection/SyliusResourceExtensionTest.php @@ -112,10 +112,13 @@ public function it_does_not_break_when_aliasing_two_resources_use_same_factory_c } /** @test */ - public function it_registers_parameter_for_paths(): void + public function it_registers_parameter_for_mapping(): void { $this->load([ 'mapping' => [ + 'imports' => [ + __DIR__ . '/php', + ], 'paths' => [ __DIR__ . '/Dummy', ], @@ -123,12 +126,32 @@ public function it_registers_parameter_for_paths(): void ]); $this->assertContainerBuilderHasParameter('sylius.resource.mapping', [ + 'imports' => [ + __DIR__ . '/php', + ], 'paths' => [ __DIR__ . '/Dummy', ], ]); } + /** @test */ + public function it_registers_metadata_configuration(): void + { + $this->load([ + 'mapping' => [ + 'imports' => [ + __DIR__ . '/php', + ], + ], + ]); + + $emptyPhpFile = realpath(__DIR__ . '/php/empty_php_file.php'); + + $this->assertContainerBuilderHasService('sylius.metadata.resource_extractor.php_file'); + $this->assertContainerBuilderHasServiceDefinitionWithArgument('sylius.metadata.resource_extractor.php_file', 0, [$emptyPhpFile]); + } + /** @test */ public function it_auto_registers_resources(): void { diff --git a/tests/Bundle/DependencyInjection/php/empty_php_file.php b/tests/Bundle/DependencyInjection/php/empty_php_file.php new file mode 100644 index 000000000..7404988e8 --- /dev/null +++ b/tests/Bundle/DependencyInjection/php/empty_php_file.php @@ -0,0 +1,12 @@ +