diff --git a/src/Metadata/Extractor/PhpFileResourceExtractor.php b/src/Metadata/Extractor/PhpFileResourceExtractor.php new file mode 100644 index 00000000000..d55c0e2fc64 --- /dev/null +++ b/src/Metadata/Extractor/PhpFileResourceExtractor.php @@ -0,0 +1,60 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Metadata\Extractor; + +use ApiPlatform\Metadata\ApiResource; + +/** + * Extracts an array of metadata from a list of PHP files. + * + * @author Loïc Frémont + */ +final class PhpFileResourceExtractor extends AbstractResourceExtractor +{ + use ResourceExtractorTrait; + + /** + * {@inheritdoc} + */ + protected function extractPath(string $path): void + { + $resource = $this->getPHPFileClosure($path)(); + + if (!$resource instanceof ApiResource) { + 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/Metadata/Resource/Factory/PhpFileResourceMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/PhpFileResourceMetadataCollectionFactory.php new file mode 100644 index 00000000000..e02f7b31e3e --- /dev/null +++ b/src/Metadata/Resource/Factory/PhpFileResourceMetadataCollectionFactory.php @@ -0,0 +1,65 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Metadata\Resource\Factory; + +use ApiPlatform\Metadata\Extractor\ResourceExtractorInterface; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Operations; +use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; + +final class PhpFileResourceMetadataCollectionFactory implements ResourceMetadataCollectionFactoryInterface +{ + use OperationDefaultsTrait; + + public function __construct( + private readonly ResourceExtractorInterface $metadataExtractor, + private readonly ?ResourceMetadataCollectionFactoryInterface $decorated = null, + ) { + } + + /** + * {@inheritdoc} + */ + public function create(string $resourceClass): ResourceMetadataCollection + { + $resourceMetadataCollection = new ResourceMetadataCollection($resourceClass); + if ($this->decorated) { + $resourceMetadataCollection = $this->decorated->create($resourceClass); + } + + foreach ($this->metadataExtractor->getResources() as $resource) { + if ($resourceClass !== $resource->getClass()) { + continue; + } + + $shortName = (false !== $pos = strrpos($resourceClass, '\\')) ? substr($resourceClass, $pos + 1) : $resourceClass; + $resource = $this->getResourceWithDefaults($resourceClass, $shortName, $resource); + + $operations = []; + /** @var Operation $operation */ + foreach ($resource->getOperations() ?? new Operations() as $operation) { + [$key, $operation] = $this->getOperationWithDefaults($resource, $operation); + $operations[$key] = $operation; + } + + if ($operations) { + $resource = $resource->withOperations(new Operations($operations)); + } + + $resourceMetadataCollection[] = $resource; + } + + return $resourceMetadataCollection; + } +} diff --git a/src/Metadata/Resource/Factory/PhpFileResourceNameCollectionFactory.php b/src/Metadata/Resource/Factory/PhpFileResourceNameCollectionFactory.php new file mode 100644 index 00000000000..be341d50647 --- /dev/null +++ b/src/Metadata/Resource/Factory/PhpFileResourceNameCollectionFactory.php @@ -0,0 +1,55 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Metadata\Resource\Factory; + +use ApiPlatform\Metadata\Extractor\ResourceExtractorInterface; +use ApiPlatform\Metadata\Resource\ResourceNameCollection; + +/** + * @internal + */ +final class PhpFileResourceNameCollectionFactory implements ResourceNameCollectionFactoryInterface +{ + public function __construct( + private readonly ResourceExtractorInterface $metadataExtractor, + private readonly ?ResourceNameCollectionFactoryInterface $decorated = null, + ) { + } + + /** + * {@inheritdoc} + */ + public function create(): ResourceNameCollection + { + $classes = []; + + if ($this->decorated) { + foreach ($this->decorated->create() as $resourceClass) { + $classes[$resourceClass] = true; + } + } + + foreach ($this->metadataExtractor->getResources() as $resource) { + $resourceClass = $resource->getClass(); + + if (null === $resourceClass) { + continue; + } + + $classes[$resourceClass] = true; + } + + return new ResourceNameCollection(array_keys($classes)); + } +} diff --git a/src/Metadata/Tests/Extractor/PhpFileResourceExtractorTest.php b/src/Metadata/Tests/Extractor/PhpFileResourceExtractorTest.php new file mode 100644 index 00000000000..e3a4c076f35 --- /dev/null +++ b/src/Metadata/Tests/Extractor/PhpFileResourceExtractorTest.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Metadata\Tests\Extractor; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Extractor\PhpFileResourceExtractor; +use PHPUnit\Framework\TestCase; + +final class PhpFileResourceExtractorTest extends TestCase +{ + public function testItGetsResourcesFromPhpFileThatReturnsAnApiResource(): void + { + $extractor = new PhpFileResourceExtractor([__DIR__.'/php/valid_php_file.php']); + + $expectedResource = new ApiResource(shortName: 'dummy'); + + $this->assertEquals([$expectedResource], $extractor->getResources()); + } + + public function testItExcludesResourcesFromPhpFileThatDoesNotReturnAnApiResource(): void + { + $extractor = new PhpFileResourceExtractor([__DIR__.'/php/invalid_php_file.php']); + + $this->assertEquals([], $extractor->getResources()); + } +} diff --git a/src/Metadata/Tests/Extractor/php/invalid_php_file.php b/src/Metadata/Tests/Extractor/php/invalid_php_file.php new file mode 100644 index 00000000000..cc7f51eb4d9 --- /dev/null +++ b/src/Metadata/Tests/Extractor/php/invalid_php_file.php @@ -0,0 +1,14 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +return new stdClass(); diff --git a/src/Metadata/Tests/Extractor/php/valid_php_file.php b/src/Metadata/Tests/Extractor/php/valid_php_file.php new file mode 100644 index 00000000000..3651211a5fd --- /dev/null +++ b/src/Metadata/Tests/Extractor/php/valid_php_file.php @@ -0,0 +1,16 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +use ApiPlatform\Metadata\ApiResource; + +return new ApiResource(shortName: 'dummy'); diff --git a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index 9f7a1e5f019..59519ce7e93 100644 --- a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -320,7 +320,7 @@ private function normalizeDefaults(array $defaults): array private function registerMetadataConfiguration(ContainerBuilder $container, array $config, XmlFileLoader $loader): void { - [$xmlResources, $yamlResources] = $this->getResourcesToWatch($container, $config); + [$xmlResources, $yamlResources, $phpResources] = $this->getResourcesToWatch($container, $config); $container->setParameter('api_platform.class_name_resources', $this->getClassNameResources()); @@ -335,6 +335,7 @@ private function registerMetadataConfiguration(ContainerBuilder $container, arra } // V3 metadata + $loader->load('metadata/php.xml'); $loader->load('metadata/xml.xml'); $loader->load('metadata/links.xml'); $loader->load('metadata/property.xml'); @@ -353,6 +354,8 @@ private function registerMetadataConfiguration(ContainerBuilder $container, arra $container->getDefinition('api_platform.metadata.resource_extractor.yaml')->replaceArgument(0, $yamlResources); $container->getDefinition('api_platform.metadata.property_extractor.yaml')->replaceArgument(0, $yamlResources); } + + $container->getDefinition('api_platform.metadata.resource_extractor.php_file')->replaceArgument(0, $phpResources); } private function getClassNameResources(): array @@ -417,7 +420,32 @@ private function getResourcesToWatch(ContainerBuilder $container, array $config) } } - $resources = ['yml' => [], 'xml' => [], 'dir' => []]; + $resources = ['yml' => [], 'xml' => [], 'php' => [], 'dir' => []]; + + foreach ($config['mapping']['imports'] ?? [] as $path) { + if (is_dir($path)) { + foreach (Finder::create()->followLinks()->files()->in($path)->name('/\.php$/')->sortByName() as $file) { + $resources[$file->getExtension()][] = $file->getRealPath(); + } + + $resources['dir'][] = $path; + $container->addResource(new DirectoryResource($path, '/\.php$/')); + + continue; + } + + if ($container->fileExists($path, false)) { + if (!str_ends_with($path, '.php')) { + throw new RuntimeException(\sprintf('Unsupported mapping type in "%s", supported type is PHP.', $path)); + } + + $resources['php'][] = $path; + + continue; + } + + throw new RuntimeException(\sprintf('Could not open file or directory "%s".', $path)); + } foreach ($paths as $path) { if (is_dir($path)) { @@ -446,7 +474,7 @@ private function getResourcesToWatch(ContainerBuilder $container, array $config) $container->setParameter('api_platform.resource_class_directories', $resources['dir']); - return [$resources['xml'], $resources['yml']]; + return [$resources['xml'], $resources['yml'], $resources['php']]; } private function registerOAuthConfiguration(ContainerBuilder $container, array $config): void diff --git a/src/Symfony/Bundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/DependencyInjection/Configuration.php index f8d1e081d5c..cb359b38d15 100644 --- a/src/Symfony/Bundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/DependencyInjection/Configuration.php @@ -136,6 +136,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/Symfony/Bundle/Resources/config/metadata/php.xml b/src/Symfony/Bundle/Resources/config/metadata/php.xml new file mode 100644 index 00000000000..ef476517816 --- /dev/null +++ b/src/Symfony/Bundle/Resources/config/metadata/php.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + diff --git a/src/Symfony/Bundle/Resources/config/metadata/resource.xml b/src/Symfony/Bundle/Resources/config/metadata/resource.xml index e0db1201030..f0caa4147b8 100644 --- a/src/Symfony/Bundle/Resources/config/metadata/resource.xml +++ b/src/Symfony/Bundle/Resources/config/metadata/resource.xml @@ -24,6 +24,12 @@ %api_platform.graphql.enabled% + + + + + + diff --git a/src/Symfony/Bundle/Resources/config/metadata/resource_name.xml b/src/Symfony/Bundle/Resources/config/metadata/resource_name.xml index 55f34e31925..7cfc3132e64 100644 --- a/src/Symfony/Bundle/Resources/config/metadata/resource_name.xml +++ b/src/Symfony/Bundle/Resources/config/metadata/resource_name.xml @@ -22,6 +22,11 @@ + + + + + %api_platform.resource_class_directories% diff --git a/src/Symfony/Tests/Bundle/DependencyInjection/ApiPlatformExtensionTest.php b/src/Symfony/Tests/Bundle/DependencyInjection/ApiPlatformExtensionTest.php index f2eb7690853..3127f4e0599 100644 --- a/src/Symfony/Tests/Bundle/DependencyInjection/ApiPlatformExtensionTest.php +++ b/src/Symfony/Tests/Bundle/DependencyInjection/ApiPlatformExtensionTest.php @@ -149,6 +149,11 @@ private function assertContainerHas(array $services, array $aliases = []): void } } + private function assertContainerHasService(string $service): void + { + $this->assertTrue($this->container->hasDefinition($service), \sprintf('Service "%s" not found.', $service)); + } + private function assertNotContainerHasService(string $service): void { $this->assertFalse($this->container->hasDefinition($service), \sprintf('Service "%s" found.', $service)); @@ -286,4 +291,15 @@ public function testEventListenersConfiguration(): void $this->assertContainerHas($services, $aliases); $this->container->hasParameter('api_platform.swagger.http_auth'); } + + public function testItRegistersMetadataConfiguration(): void + { + $config = self::DEFAULT_CONFIG; + $config['api_platform']['mapping']['imports'] = [__DIR__.'/php']; + (new ApiPlatformExtension())->load($config, $this->container); + + $emptyPhpFile = realpath(__DIR__.'/php/empty_file.php'); + $this->assertContainerHasService('api_platform.metadata.resource_extractor.php_file'); + $this->assertSame([$emptyPhpFile], $this->container->getDefinition('api_platform.metadata.resource_extractor.php_file')->getArgument(0)); + } } diff --git a/src/Symfony/Tests/Bundle/DependencyInjection/php/empty_file.php b/src/Symfony/Tests/Bundle/DependencyInjection/php/empty_file.php new file mode 100644 index 00000000000..6733fc7e243 --- /dev/null +++ b/src/Symfony/Tests/Bundle/DependencyInjection/php/empty_file.php @@ -0,0 +1,12 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); diff --git a/src/Symfony/composer.json b/src/Symfony/composer.json index 090750c80fe..ecf4d2aa451 100644 --- a/src/Symfony/composer.json +++ b/src/Symfony/composer.json @@ -39,6 +39,7 @@ "api-platform/state": "^4.1.11", "api-platform/validator": "^4.1.11", "api-platform/openapi": "^4.1.11", + "symfony/finder": "^6.4 || ^7.0", "symfony/property-info": "^6.4 || ^7.1", "symfony/property-access": "^6.4 || ^7.0", "symfony/serializer": "^6.4 || ^7.0", diff --git a/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php b/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php index bbb1dc8e659..568e3f7bd31 100644 --- a/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php +++ b/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php @@ -175,6 +175,7 @@ private function runDefaultConfigTests(array $doctrineIntegrationsToLoad = ['orm ], ], 'mapping' => [ + 'imports' => [], 'paths' => [], ], 'http_cache' => [