diff --git a/src/Laravel/ApiPlatformProvider.php b/src/Laravel/ApiPlatformProvider.php index 889f7703b49..c5daa0d193b 100644 --- a/src/Laravel/ApiPlatformProvider.php +++ b/src/Laravel/ApiPlatformProvider.php @@ -98,6 +98,7 @@ use ApiPlatform\Laravel\JsonApi\State\JsonApiProvider; use ApiPlatform\Laravel\Metadata\CachePropertyMetadataFactory; use ApiPlatform\Laravel\Metadata\CachePropertyNameCollectionMetadataFactory; +use ApiPlatform\Laravel\Metadata\DumpedResourceCollectionMetadataFactory; use ApiPlatform\Laravel\Routing\IriConverter; use ApiPlatform\Laravel\Routing\Router as UrlGeneratorRouter; use ApiPlatform\Laravel\Routing\SkolemIriConverter; @@ -402,6 +403,20 @@ public function register(): void return new Metadata\Resource\Factory\ParameterResourceMetadataCollectionFactory($inner, $app->make(ModelMetadata::class), new \Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter()); }); + // Outermost: serve the resource metadata from a dumped file so the app can boot without a + // live database. Skipped when APP_DEBUG is true so local development always recomputes fresh + // metadata (mirroring the 'array' cache choice). + $this->app->extend(ResourceMetadataCollectionFactoryInterface::class, static function (ResourceMetadataCollectionFactoryInterface $inner, Application $app) { + /** @var ConfigRepository $config */ + $config = $app['config']; + + if (true === $config->get('app.debug')) { + return $inner; + } + + return new DumpedResourceCollectionMetadataFactory($inner, $config->get('api-platform.metadata_dump')); + }); + $this->app->singleton(OperationMetadataFactory::class, static function (Application $app) { return new OperationMetadataFactory($app->make(ResourceNameCollectionFactoryInterface::class), $app->make(ResourceMetadataCollectionFactoryInterface::class)); }); @@ -1110,6 +1125,7 @@ public function register(): void if ($this->app->runningInConsole()) { $this->commands([ Console\InstallCommand::class, + Console\DumpMetadataCommand::class, Console\Maker\MakeStateProcessorCommand::class, Console\Maker\MakeStateProviderCommand::class, Console\Maker\MakeFilterCommand::class, diff --git a/src/Laravel/Console/DumpMetadataCommand.php b/src/Laravel/Console/DumpMetadataCommand.php new file mode 100644 index 00000000000..6b05aff25f0 --- /dev/null +++ b/src/Laravel/Console/DumpMetadataCommand.php @@ -0,0 +1,80 @@ + + * + * 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\Laravel\Console; + +use ApiPlatform\Laravel\Metadata\DumpedResourceCollectionMetadataFactory; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; +use Illuminate\Console\Command; +use Symfony\Component\Console\Attribute\AsCommand; + +#[AsCommand(name: 'api-platform:metadata:dump')] +final class DumpMetadataCommand extends Command +{ + /** + * @var string + */ + protected $signature = 'api-platform:metadata:dump {--path= : Where to write the dumped metadata file (defaults to the api-platform.metadata_dump config value)}'; + + /** + * @var string + */ + protected $description = 'Dump the resource metadata to a file so the app can boot without hitting the database'; + + public function __construct( + private readonly ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, + private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, + ) { + parent::__construct(); + } + + public function handle(): int + { + $path = $this->option('path') ?: config('api-platform.metadata_dump'); + + if (!\is_string($path) || '' === $path) { + $this->error('No dump path configured. Pass --path or set the "api-platform.metadata_dump" config value.'); + + return self::FAILURE; + } + + // Always rebuild from the live source, never from a previously dumped (possibly stale) file. + $factory = $this->resourceMetadataCollectionFactory; + while ($factory instanceof DumpedResourceCollectionMetadataFactory) { + $factory = $factory->getDecorated(); + } + + $metadata = []; + foreach ($this->resourceNameCollectionFactory->create() as $resourceClass) { + $metadata[$resourceClass] = $factory->create($resourceClass); + } + + $directory = \dirname($path); + if (!is_dir($directory) && !mkdir($directory, 0o755, true) && !is_dir($directory)) { + $this->error(\sprintf('Unable to create directory "%s".', $directory)); + + return self::FAILURE; + } + + if (false === file_put_contents($path, serialize($metadata))) { + $this->error(\sprintf('Unable to write the metadata dump to "%s".', $path)); + + return self::FAILURE; + } + + $this->info(\sprintf('Dumped metadata for %d resource(s) to "%s".', \count($metadata), $path)); + + return self::SUCCESS; + } +} diff --git a/src/Laravel/Metadata/DumpedResourceCollectionMetadataFactory.php b/src/Laravel/Metadata/DumpedResourceCollectionMetadataFactory.php new file mode 100644 index 00000000000..916c0e68f6c --- /dev/null +++ b/src/Laravel/Metadata/DumpedResourceCollectionMetadataFactory.php @@ -0,0 +1,75 @@ + + * + * 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\Laravel\Metadata; + +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; + +/** + * Serves the resource metadata from a file dumped by api-platform:metadata:dump, bypassing the + * database introspection that happens while building the collection. Delegates to the decorated + * factory for any resource missing from the dump (or when no dump file exists). + */ +final class DumpedResourceCollectionMetadataFactory implements ResourceMetadataCollectionFactoryInterface +{ + /** + * @var array|null + */ + private ?array $dumped = null; + + public function __construct( + private readonly ResourceMetadataCollectionFactoryInterface $decorated, + private readonly ?string $dumpPath, + ) { + } + + public function create(string $resourceClass): ResourceMetadataCollection + { + $dumped = $this->load(); + + return $dumped[$resourceClass] ?? $this->decorated->create($resourceClass); + } + + /** + * Exposes the decorated factory so the dump command can rebuild metadata from the live source + * instead of reading back a previously dumped (possibly stale) file. + */ + public function getDecorated(): ResourceMetadataCollectionFactoryInterface + { + return $this->decorated; + } + + /** + * @return array + */ + private function load(): array + { + if (null !== $this->dumped) { + return $this->dumped; + } + + if (null === $this->dumpPath || !is_file($this->dumpPath)) { + return $this->dumped = []; + } + + $contents = file_get_contents($this->dumpPath); + if (false === $contents) { + return $this->dumped = []; + } + + $data = unserialize($contents, ['allowed_classes' => true]); + + return $this->dumped = \is_array($data) ? $data : []; + } +} diff --git a/src/Laravel/Tests/Console/DumpMetadataCommandTest.php b/src/Laravel/Tests/Console/DumpMetadataCommandTest.php new file mode 100644 index 00000000000..9028df5eb91 --- /dev/null +++ b/src/Laravel/Tests/Console/DumpMetadataCommandTest.php @@ -0,0 +1,143 @@ + + * + * 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\Laravel\Tests\Console; + +use ApiPlatform\Laravel\Metadata\DumpedResourceCollectionMetadataFactory; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; +use ApiPlatform\Metadata\Resource\ResourceNameCollection; +use Illuminate\Console\Command; +use Illuminate\Testing\PendingCommand; +use Orchestra\Testbench\Concerns\WithWorkbench; +use Orchestra\Testbench\TestCase; + +class DumpMetadataCommandTest extends TestCase +{ + use WithWorkbench; + + private string $dumpPath; + + protected function setUp(): void + { + parent::setUp(); + + $this->dumpPath = tempnam(sys_get_temp_dir(), 'apip_dump_cmd_').'.meta'; + @unlink($this->dumpPath); + } + + protected function tearDown(): void + { + if (is_file($this->dumpPath)) { + unlink($this->dumpPath); + } + + parent::tearDown(); + } + + public function testItDumpsTheResourceMetadataCollectionMapToTheGivenFile(): void + { + $classOne = 'App\\Resource\\One'; + $classTwo = 'App\\Resource\\Two'; + + $collectionOne = new ResourceMetadataCollection($classOne, [new ApiResource(shortName: 'One')]); + $collectionTwo = new ResourceMetadataCollection($classTwo, [new ApiResource(shortName: 'Two')]); + + $nameFactory = $this->createStub(ResourceNameCollectionFactoryInterface::class); + $nameFactory->method('create')->willReturn(new ResourceNameCollection([$classOne, $classTwo])); + + $metadataFactory = $this->createStub(ResourceMetadataCollectionFactoryInterface::class); + $metadataFactory->method('create')->willReturnCallback(static fn (string $class): ResourceMetadataCollection => match ($class) { + $classOne => $collectionOne, + $classTwo => $collectionTwo, + default => throw new \LogicException(\sprintf('Unexpected class "%s".', $class)), + }); + + $this->app->instance(ResourceNameCollectionFactoryInterface::class, $nameFactory); + $this->app->instance(ResourceMetadataCollectionFactoryInterface::class, $metadataFactory); + + $this->runDump() + ->expectsOutputToContain('Dumped metadata for') + ->assertExitCode(Command::SUCCESS); + + $this->assertFileExists($this->dumpPath); + + $dumped = $this->readDump(); + + $this->assertArrayHasKey($classOne, $dumped); + $this->assertArrayHasKey($classTwo, $dumped); + $this->assertEquals($collectionOne, $dumped[$classOne]); + $this->assertEquals($collectionTwo, $dumped[$classTwo]); + } + + public function testItRebuildsFromTheLiveSourceEvenWhenTheResolvedFactoryIsTheDumpedDecorator(): void + { + $class = 'App\\Resource\\Fresh'; + + $fresh = new ResourceMetadataCollection($class, [new ApiResource(shortName: 'Fresh')]); + $stale = new ResourceMetadataCollection($class, [new ApiResource(shortName: 'Stale')]); + + // Simulate an already-present (stale) dump on disk. + file_put_contents($this->dumpPath, serialize([$class => $stale])); + + $nameFactory = $this->createStub(ResourceNameCollectionFactoryInterface::class); + $nameFactory->method('create')->willReturn(new ResourceNameCollection([$class])); + + $live = $this->createStub(ResourceMetadataCollectionFactoryInterface::class); + $live->method('create')->willReturn($fresh); + + // The resolved factory is a DumpedResourceCollectionMetadataFactory pointing at the stale file. + $dumpedFactory = new DumpedResourceCollectionMetadataFactory($live, $this->dumpPath); + + $this->app->instance(ResourceNameCollectionFactoryInterface::class, $nameFactory); + $this->app->instance(ResourceMetadataCollectionFactoryInterface::class, $dumpedFactory); + + $this->runDump() + ->expectsOutputToContain('Dumped metadata for') + ->assertExitCode(Command::SUCCESS); + + $dumped = $this->readDump(); + + $this->assertEquals($fresh, $dumped[$class]); + } + + private function runDump(): PendingCommand + { + $command = $this->artisan('api-platform:metadata:dump', ['--path' => $this->dumpPath]); + if (!$command instanceof PendingCommand) { + $this->fail('artisan() did not return a PendingCommand.'); + } + + return $command; + } + + /** + * @return array + */ + private function readDump(): array + { + $contents = file_get_contents($this->dumpPath); + if (false === $contents) { + $this->fail(\sprintf('Unable to read the dump file "%s".', $this->dumpPath)); + } + + $dumped = unserialize($contents, ['allowed_classes' => true]); + if (!\is_array($dumped)) { + $this->fail('The dump file did not contain an array.'); + } + + return $dumped; + } +} diff --git a/src/Laravel/Tests/Metadata/DumpedMetadataBootTest.php b/src/Laravel/Tests/Metadata/DumpedMetadataBootTest.php new file mode 100644 index 00000000000..0b1984fa126 --- /dev/null +++ b/src/Laravel/Tests/Metadata/DumpedMetadataBootTest.php @@ -0,0 +1,79 @@ + + * + * 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\Laravel\Tests\Metadata; + +use ApiPlatform\Laravel\Metadata\DumpedResourceCollectionMetadataFactory; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; +use Orchestra\Testbench\Concerns\WithWorkbench; +use Orchestra\Testbench\TestCase; + +class DumpedMetadataBootTest extends TestCase +{ + use WithWorkbench; + + private const RESOURCE_CLASS = 'App\\NotAnEloquentModel'; + + private string $dumpPath; + + protected function setUp(): void + { + $this->dumpPath = tempnam(sys_get_temp_dir(), 'apip_boot_dump_').'.meta'; + + $dumped = new ResourceMetadataCollection(self::RESOURCE_CLASS, [new ApiResource(shortName: 'FromDump')]); + file_put_contents($this->dumpPath, serialize([self::RESOURCE_CLASS => $dumped])); + + parent::setUp(); + } + + protected function tearDown(): void + { + if (is_file($this->dumpPath)) { + unlink($this->dumpPath); + } + + parent::tearDown(); + } + + protected function defineEnvironment($app): void + { + $app['config']->set('app.debug', false); + $app['config']->set('api-platform.metadata_dump', $this->dumpPath); + } + + public function testItServesMetadataFromTheDumpWithoutHittingTheDatabase(): void + { + $factory = $this->app->make(ResourceMetadataCollectionFactoryInterface::class); + + $this->assertInstanceOf(DumpedResourceCollectionMetadataFactory::class, $factory); + + // The class is not a real Eloquent model; if the dump were not consulted the inner + // factory chain would try to introspect a non-existent model/table. + $metadata = $factory->create(self::RESOURCE_CLASS); + + $this->assertCount(1, $metadata); + $this->assertSame('FromDump', $metadata[0]->getShortName()); + } + + public function testItIsNotWrappedWhenDebugIsEnabled(): void + { + $this->app['config']->set('app.debug', true); + $this->app->forgetInstance(ResourceMetadataCollectionFactoryInterface::class); + + $factory = $this->app->make(ResourceMetadataCollectionFactoryInterface::class); + + $this->assertNotInstanceOf(DumpedResourceCollectionMetadataFactory::class, $factory); + } +} diff --git a/src/Laravel/Tests/Unit/Metadata/DumpedResourceCollectionMetadataFactoryTest.php b/src/Laravel/Tests/Unit/Metadata/DumpedResourceCollectionMetadataFactoryTest.php new file mode 100644 index 00000000000..4ca95f5d293 --- /dev/null +++ b/src/Laravel/Tests/Unit/Metadata/DumpedResourceCollectionMetadataFactoryTest.php @@ -0,0 +1,88 @@ + + * + * 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\Laravel\Tests\Unit\Metadata; + +use ApiPlatform\Laravel\Metadata\DumpedResourceCollectionMetadataFactory; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; +use PHPUnit\Framework\TestCase; +use Workbench\App\Models\Book; + +class DumpedResourceCollectionMetadataFactoryTest extends TestCase +{ + private string $dumpPath; + + protected function setUp(): void + { + $this->dumpPath = tempnam(sys_get_temp_dir(), 'apip_dump_').'.meta'; + } + + protected function tearDown(): void + { + if (is_file($this->dumpPath)) { + unlink($this->dumpPath); + } + } + + public function testItReturnsTheDumpedCollectionWithoutCallingTheDecoratedFactory(): void + { + $dumped = new ResourceMetadataCollection(Book::class, [new ApiResource(shortName: 'Book')]); + file_put_contents($this->dumpPath, serialize([Book::class => $dumped])); + + $decorated = $this->createMock(ResourceMetadataCollectionFactoryInterface::class); + $decorated->expects($this->never())->method('create'); + + $factory = new DumpedResourceCollectionMetadataFactory($decorated, $this->dumpPath); + + $result = $factory->create(Book::class); + + $this->assertEquals($dumped, $result); + } + + public function testItDelegatesForAClassMissingFromTheDump(): void + { + file_put_contents($this->dumpPath, serialize([Book::class => new ResourceMetadataCollection(Book::class, [])])); + + $expected = new ResourceMetadataCollection('Unknown', [new ApiResource(shortName: 'Unknown')]); + $decorated = $this->createMock(ResourceMetadataCollectionFactoryInterface::class); + $decorated->expects($this->once())->method('create')->with('Unknown')->willReturn($expected); + + $factory = new DumpedResourceCollectionMetadataFactory($decorated, $this->dumpPath); + + $this->assertSame($expected, $factory->create('Unknown')); + } + + public function testItDelegatesWhenNoDumpPathConfigured(): void + { + $expected = new ResourceMetadataCollection(Book::class, [new ApiResource(shortName: 'Book')]); + $decorated = $this->createMock(ResourceMetadataCollectionFactoryInterface::class); + $decorated->expects($this->once())->method('create')->with(Book::class)->willReturn($expected); + + $factory = new DumpedResourceCollectionMetadataFactory($decorated, null); + + $this->assertSame($expected, $factory->create(Book::class)); + } + + public function testItDelegatesWhenDumpFileIsAbsent(): void + { + $expected = new ResourceMetadataCollection(Book::class, [new ApiResource(shortName: 'Book')]); + $decorated = $this->createMock(ResourceMetadataCollectionFactoryInterface::class); + $decorated->expects($this->once())->method('create')->with(Book::class)->willReturn($expected); + + $factory = new DumpedResourceCollectionMetadataFactory($decorated, '/nonexistent/path/api_platform_metadata.meta'); + + $this->assertSame($expected, $factory->create(Book::class)); + } +} diff --git a/src/Laravel/config/api-platform.php b/src/Laravel/config/api-platform.php index 38e0de76e9f..b9243f79594 100644 --- a/src/Laravel/config/api-platform.php +++ b/src/Laravel/config/api-platform.php @@ -178,6 +178,13 @@ // we recommend using "file" or "acpu" 'cache' => 'file', + // Path to a resource metadata file produced by `php artisan api-platform:metadata:dump`. + // When set (and APP_DEBUG is false), the metadata is read from this file at boot instead + // of being introspected from the database, allowing the app to boot without a live DB + // (e.g. during `docker build`, `composer install`, or static analysis in CI). Commit the + // file to VCS or bake it into your image. Leave null to disable. + 'metadata_dump' => null, + // MCP (Model Context Protocol) configuration 'mcp' => [ 'enabled' => true,