diff --git a/src/Metadata/Extractor/PhpFileResourceExtractor.php b/src/Metadata/Extractor/PhpFileResourceExtractor.php new file mode 100644 index 0000000000..d55c0e2fc6 --- /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 0000000000..e02f7b31e3 --- /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 0000000000..be341d5064 --- /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 0000000000..1b7c4451ac --- /dev/null +++ b/src/Metadata/Tests/Extractor/PhpFileResourceExtractorTest.php @@ -0,0 +1,28 @@ +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 0000000000..09b52b82cf --- /dev/null +++ b/src/Metadata/Tests/Extractor/php/invalid_php_file.php @@ -0,0 +1,5 @@ +getResourcesToWatch($container, $config); + [$xmlResources, $yamlResources, $phpResources] = $this->getResourcesToWatch($container, $config); $container->setParameter('api_platform.class_name_resources', $this->getClassNameResources()); @@ -320,6 +320,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'); @@ -338,6 +339,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 @@ -402,7 +405,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 (!preg_match('/\.php$/', (string) $path, $matches)) { + throw new RuntimeException(\sprintf('Unsupported mapping type in "%s", supported type is PHP.', $path)); + } + + $resources['php' === $matches[1]][] = $path; + + continue; + } + + throw new RuntimeException(\sprintf('Could not open file or directory "%s".', $path)); + } foreach ($paths as $path) { if (is_dir($path)) { @@ -431,7 +459,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 9460dfcbce..2d1eebeb6e 100644 --- a/src/Symfony/Bundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/DependencyInjection/Configuration.php @@ -134,6 +134,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 0000000000..ef47651781 --- /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 e0db120103..f0caa4147b 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 55f34e3192..7cfc3132e6 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 f2eb769085..aa89f6dcb0 100644 --- a/src/Symfony/Tests/Bundle/DependencyInjection/ApiPlatformExtensionTest.php +++ b/src/Symfony/Tests/Bundle/DependencyInjection/ApiPlatformExtensionTest.php @@ -29,11 +29,13 @@ use ApiPlatform\Tests\Fixtures\TestBundle\TestBundle; use Doctrine\Bundle\DoctrineBundle\DoctrineBundle; use Doctrine\ORM\OptimisticLockException; +use PHPUnit\Framework\Constraint\IsEqual; use PHPUnit\Framework\TestCase; use Symfony\Bundle\SecurityBundle\SecurityBundle; use Symfony\Bundle\TwigBundle\TwigBundle; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\ParameterBag\ParameterBag; +use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\HttpFoundation\Response; class ApiPlatformExtensionTest extends TestCase @@ -149,6 +151,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 +293,20 @@ public function testEventListenersConfiguration(): void $this->assertContainerHas($services, $aliases); $this->container->hasParameter('api_platform.swagger.http_auth'); } + + public function testItRegisterMetadataConfiguration(): 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'); + + $service = $this->container->get('api_platform.metadata.resource_extractor.php_file'); + $reflection = new \ReflectionClass($service); + + $this->assertSame([$emptyPhpFile], $reflection->getProperty('paths')->getValue($service)); + } } 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 0000000000..b3d9bbc7f3 --- /dev/null +++ b/src/Symfony/Tests/Bundle/DependencyInjection/php/empty_file.php @@ -0,0 +1 @@ + [ + 'imports' => [], 'paths' => [], ], 'http_cache' => [