From 62b74ce0399437eecb81572505cab7488355ff55 Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 11 Jun 2026 17:14:00 +0200 Subject: [PATCH 1/2] feat(laravel): boot without a database via dumped metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit API Platform reads Eloquent model metadata from the live database schema while building resource metadata. That build runs at boot (route registration iterates every resource), so the app cannot boot when no migrated database is reachable — breaking Docker image builds, `composer install` (package:discover) and static analysis in CI. Add `api-platform:metadata:dump`, which computes every resource's ResourceMetadataCollection with the database up and serializes the map to a file. A new DumpedResourceCollectionMetadataFactory is wired as the outermost resource metadata factory: when a dump file is configured it serves metadata from the file and short-circuits the database-reading factories. The decorator is skipped when APP_DEBUG is true so local development always recomputes fresh metadata. The dump file can be committed to the repository or baked into a Docker image, letting the app boot with no database connection. Refs #8131 --- src/Laravel/ApiPlatformProvider.php | 16 ++ src/Laravel/Console/DumpMetadataCommand.php | 80 ++++++++++ ...umpedResourceCollectionMetadataFactory.php | 75 +++++++++ .../Tests/Console/DumpMetadataCommandTest.php | 143 ++++++++++++++++++ .../Tests/Metadata/DumpedMetadataBootTest.php | 79 ++++++++++ ...dResourceCollectionMetadataFactoryTest.php | 88 +++++++++++ src/Laravel/config/api-platform.php | 7 + 7 files changed, 488 insertions(+) create mode 100644 src/Laravel/Console/DumpMetadataCommand.php create mode 100644 src/Laravel/Metadata/DumpedResourceCollectionMetadataFactory.php create mode 100644 src/Laravel/Tests/Console/DumpMetadataCommandTest.php create mode 100644 src/Laravel/Tests/Metadata/DumpedMetadataBootTest.php create mode 100644 src/Laravel/Tests/Unit/Metadata/DumpedResourceCollectionMetadataFactoryTest.php 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, From 2400ef0c6140bf0b715ececda03b7478fabdefd1 Mon Sep 17 00:00:00 2001 From: soyuka Date: Sat, 13 Jun 2026 07:26:51 +0200 Subject: [PATCH 2/2] feat(laravel): detect stale metadata dump via fingerprints The dumped metadata is a frozen snapshot served outermost (above the cache) when APP_DEBUG is false, so a changed resource or migrated schema is served stale with no signal. Detect both drift axes, warn-only, without breaking the no-database boot the dump provides: - resource drift: hash the ApiResource source files (content, not mtime, so committed/baked dumps survive git clone and docker build) and warn at boot when they differ from the dumped fingerprint; - schema drift: hash the live Eloquent schema and warn on MigrationsEnded when it differs (DB only reachable then). The dump file gains a versioned envelope carrying both fingerprints; bare-map dumps from the previous format still load without a warning. Refs #8131, #8290 --- src/Laravel/ApiPlatformProvider.php | 39 +++++++- src/Laravel/Console/DumpMetadataCommand.php | 15 ++- ...umpedResourceCollectionMetadataFactory.php | 37 ++++++- .../Metadata/MetadataDumpFingerprint.php | 99 +++++++++++++++++++ .../Tests/Console/DumpMetadataCommandTest.php | 16 ++- .../DumpedMetadataMigrationStalenessTest.php | 89 +++++++++++++++++ ...dResourceCollectionMetadataFactoryTest.php | 67 +++++++++++++ .../Metadata/MetadataDumpFingerprintTest.php | 90 +++++++++++++++++ 8 files changed, 444 insertions(+), 8 deletions(-) create mode 100644 src/Laravel/Metadata/MetadataDumpFingerprint.php create mode 100644 src/Laravel/Tests/Metadata/DumpedMetadataMigrationStalenessTest.php create mode 100644 src/Laravel/Tests/Unit/Metadata/MetadataDumpFingerprintTest.php diff --git a/src/Laravel/ApiPlatformProvider.php b/src/Laravel/ApiPlatformProvider.php index c5daa0d193b..cbce51bfe1e 100644 --- a/src/Laravel/ApiPlatformProvider.php +++ b/src/Laravel/ApiPlatformProvider.php @@ -99,6 +99,7 @@ use ApiPlatform\Laravel\Metadata\CachePropertyMetadataFactory; use ApiPlatform\Laravel\Metadata\CachePropertyNameCollectionMetadataFactory; use ApiPlatform\Laravel\Metadata\DumpedResourceCollectionMetadataFactory; +use ApiPlatform\Laravel\Metadata\MetadataDumpFingerprint; use ApiPlatform\Laravel\Routing\IriConverter; use ApiPlatform\Laravel\Routing\Router as UrlGeneratorRouter; use ApiPlatform\Laravel\Routing\SkolemIriConverter; @@ -175,6 +176,7 @@ use Http\Discovery\Psr17Factory; use Illuminate\Config\Repository as ConfigRepository; use Illuminate\Contracts\Foundation\Application; +use Illuminate\Database\Events\MigrationsEnded; use Illuminate\Foundation\Http\Events\RequestHandled; use Illuminate\Routing\Router; use Illuminate\Support\Facades\Event; @@ -414,7 +416,7 @@ public function register(): void return $inner; } - return new DumpedResourceCollectionMetadataFactory($inner, $config->get('api-platform.metadata_dump')); + return new DumpedResourceCollectionMetadataFactory($inner, $config->get('api-platform.metadata_dump'), $app->make(LoggerInterface::class), $config->get('api-platform.resources') ?? []); }); $this->app->singleton(OperationMetadataFactory::class, static function (Application $app) { @@ -1498,6 +1500,41 @@ public function boot(): void $this->app->make(SkolemIriConverter::class)->reset(); }); + // The schema can only be fingerprinted while the database is reachable, so detect schema + // drift against the dump right after a migration runs (warn-only — the dump is not rewritten). + $dumpPath = $config->get('api-platform.metadata_dump'); + if (\is_string($dumpPath) && '' !== $dumpPath && true !== $config->get('app.debug')) { + Event::listen(MigrationsEnded::class, function () use ($dumpPath): void { + $this->warnIfDumpedSchemaIsStale($dumpPath); + }); + } + $this->loadRoutesFrom(__DIR__.'/routes/api.php'); } + + private function warnIfDumpedSchemaIsStale(string $dumpPath): void + { + if (!is_file($dumpPath)) { + return; + } + + $contents = file_get_contents($dumpPath); + if (false === $contents) { + return; + } + + $data = unserialize($contents, ['allowed_classes' => true]); + if (!\is_array($data) || !\is_string($data['schema_fingerprint'] ?? null)) { + return; + } + + $resourceClasses = $this->app->make(ResourceNameCollectionFactoryInterface::class)->create(); + $current = MetadataDumpFingerprint::schema($resourceClasses, $this->app->make(ModelMetadata::class)); + + if ($current === $data['schema_fingerprint']) { + return; + } + + $this->app->make(LoggerInterface::class)->warning('The API Platform metadata dump at "{path}" is stale: the database schema changed after migration. Run "php artisan api-platform:metadata:dump" to refresh it.', ['path' => $dumpPath]); + } } diff --git a/src/Laravel/Console/DumpMetadataCommand.php b/src/Laravel/Console/DumpMetadataCommand.php index 6b05aff25f0..9dac11ddcf1 100644 --- a/src/Laravel/Console/DumpMetadataCommand.php +++ b/src/Laravel/Console/DumpMetadataCommand.php @@ -13,7 +13,9 @@ namespace ApiPlatform\Laravel\Console; +use ApiPlatform\Laravel\Eloquent\Metadata\ModelMetadata; use ApiPlatform\Laravel\Metadata\DumpedResourceCollectionMetadataFactory; +use ApiPlatform\Laravel\Metadata\MetadataDumpFingerprint; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; use Illuminate\Console\Command; @@ -35,6 +37,7 @@ final class DumpMetadataCommand extends Command public function __construct( private readonly ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, + private readonly ModelMetadata $modelMetadata, ) { parent::__construct(); } @@ -60,6 +63,16 @@ public function handle(): int $metadata[$resourceClass] = $factory->create($resourceClass); } + // Fingerprints let the app detect a stale dump later: resources at boot (no DB), + // schema after a migration (DB up). Computed here while the live source is available. + $resourcePaths = config('api-platform.resources') ?? []; + $envelope = [ + 'version' => MetadataDumpFingerprint::VERSION, + 'resources_fingerprint' => MetadataDumpFingerprint::resources($resourcePaths), + 'schema_fingerprint' => MetadataDumpFingerprint::schema(array_keys($metadata), $this->modelMetadata), + 'metadata' => $metadata, + ]; + $directory = \dirname($path); if (!is_dir($directory) && !mkdir($directory, 0o755, true) && !is_dir($directory)) { $this->error(\sprintf('Unable to create directory "%s".', $directory)); @@ -67,7 +80,7 @@ public function handle(): int return self::FAILURE; } - if (false === file_put_contents($path, serialize($metadata))) { + if (false === file_put_contents($path, serialize($envelope))) { $this->error(\sprintf('Unable to write the metadata dump to "%s".', $path)); return self::FAILURE; diff --git a/src/Laravel/Metadata/DumpedResourceCollectionMetadataFactory.php b/src/Laravel/Metadata/DumpedResourceCollectionMetadataFactory.php index 916c0e68f6c..b995fe6d38a 100644 --- a/src/Laravel/Metadata/DumpedResourceCollectionMetadataFactory.php +++ b/src/Laravel/Metadata/DumpedResourceCollectionMetadataFactory.php @@ -15,11 +15,17 @@ use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; +use Psr\Log\LoggerInterface; /** * 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). + * + * When the dump carries a resources fingerprint, it is checked against the current source files + * once on load: a mismatch logs a warning (the dump is still served, so the no-database boot keeps + * working) telling the operator to re-run api-platform:metadata:dump. Database schema drift cannot + * be detected here without a connection; it is reported by the migrate listener instead. */ final class DumpedResourceCollectionMetadataFactory implements ResourceMetadataCollectionFactoryInterface { @@ -28,9 +34,14 @@ final class DumpedResourceCollectionMetadataFactory implements ResourceMetadataC */ private ?array $dumped = null; + /** + * @param list $resourcePaths + */ public function __construct( private readonly ResourceMetadataCollectionFactoryInterface $decorated, private readonly ?string $dumpPath, + private readonly ?LoggerInterface $logger = null, + private readonly array $resourcePaths = [], ) { } @@ -69,7 +80,31 @@ private function load(): array } $data = unserialize($contents, ['allowed_classes' => true]); + if (!\is_array($data)) { + return $this->dumped = []; + } + + // An envelope (version >= 1) carries fingerprints; a bare map is an older dump with no + // freshness information — serve it as-is without a staleness check. + if (!isset($data['version'], $data['metadata']) || !\is_array($data['metadata'])) { + return $this->dumped = $data; + } + + $this->warnIfResourcesChanged(\is_string($data['resources_fingerprint'] ?? null) ? $data['resources_fingerprint'] : null); + + return $this->dumped = $data['metadata']; + } + + private function warnIfResourcesChanged(?string $dumpedFingerprint): void + { + if (null === $dumpedFingerprint || null === $this->logger || [] === $this->resourcePaths) { + return; + } + + if (MetadataDumpFingerprint::resources($this->resourcePaths) === $dumpedFingerprint) { + return; + } - return $this->dumped = \is_array($data) ? $data : []; + $this->logger->warning('The API Platform metadata dump at "{path}" is stale: resource files changed since it was generated. Run "php artisan api-platform:metadata:dump" to refresh it.', ['path' => $this->dumpPath]); } } diff --git a/src/Laravel/Metadata/MetadataDumpFingerprint.php b/src/Laravel/Metadata/MetadataDumpFingerprint.php new file mode 100644 index 00000000000..e1f21b0dffc --- /dev/null +++ b/src/Laravel/Metadata/MetadataDumpFingerprint.php @@ -0,0 +1,99 @@ + + * + * 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\Laravel\Eloquent\Metadata\ModelMetadata; +use Illuminate\Database\Eloquent\Model; + +/** + * Computes freshness fingerprints for the metadata dump written by api-platform:metadata:dump. + * + * Two independent axes, because the dump exists to let the app boot without a database: + * - resources(): hashes the ApiResource source files, so it can run at boot with no DB; + * - schema(): hashes the live Eloquent schema, so it can only run when a DB is reachable. + */ +final class MetadataDumpFingerprint +{ + public const VERSION = 1; + + /** + * Content hash of every PHP file under the given resource paths. + * + * Content-based (not filemtime) on purpose: a committed or image-baked dump must stay valid + * across `git clone` and `docker build`, neither of which preserves modification times. + * + * @param list $paths + */ + public static function resources(array $paths): string + { + $hashes = []; + foreach ($paths as $path) { + if (is_file($path)) { + $hashes[$path] = sha1_file($path) ?: ''; + continue; + } + + if (!is_dir($path)) { + continue; + } + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS), + ); + foreach ($iterator as $file) { + if (!$file instanceof \SplFileInfo || 'php' !== $file->getExtension()) { + continue; + } + + $pathname = $file->getPathname(); + $hashes[$pathname] = sha1_file($pathname) ?: ''; + } + } + + ksort($hashes); + + return hash('xxh128', serialize($hashes)); + } + + /** + * Hash of the live Eloquent schema (columns + relations) for every model-backed resource. + * Requires a database connection. + * + * @param iterable $resourceClasses + */ + public static function schema(iterable $resourceClasses, ModelMetadata $modelMetadata): string + { + $signature = []; + foreach ($resourceClasses as $resourceClass) { + try { + $model = (new \ReflectionClass($resourceClass))->newInstanceWithoutConstructor(); + } catch (\ReflectionException) { + continue; + } + + if (!$model instanceof Model) { + continue; + } + + $signature[$resourceClass] = [ + 'attributes' => $modelMetadata->getAttributes($model), + 'relations' => $modelMetadata->getRelations($model), + ]; + } + + ksort($signature); + + return hash('xxh128', serialize($signature)); + } +} diff --git a/src/Laravel/Tests/Console/DumpMetadataCommandTest.php b/src/Laravel/Tests/Console/DumpMetadataCommandTest.php index 9028df5eb91..81096113616 100644 --- a/src/Laravel/Tests/Console/DumpMetadataCommandTest.php +++ b/src/Laravel/Tests/Console/DumpMetadataCommandTest.php @@ -14,6 +14,7 @@ namespace ApiPlatform\Laravel\Tests\Console; use ApiPlatform\Laravel\Metadata\DumpedResourceCollectionMetadataFactory; +use ApiPlatform\Laravel\Metadata\MetadataDumpFingerprint; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; @@ -76,10 +77,15 @@ public function testItDumpsTheResourceMetadataCollectionMapToTheGivenFile(): voi $dumped = $this->readDump(); - $this->assertArrayHasKey($classOne, $dumped); - $this->assertArrayHasKey($classTwo, $dumped); - $this->assertEquals($collectionOne, $dumped[$classOne]); - $this->assertEquals($collectionTwo, $dumped[$classTwo]); + $this->assertSame(MetadataDumpFingerprint::VERSION, $dumped['version']); + $this->assertIsString($dumped['resources_fingerprint']); + $this->assertIsString($dumped['schema_fingerprint']); + + $metadata = $dumped['metadata']; + $this->assertArrayHasKey($classOne, $metadata); + $this->assertArrayHasKey($classTwo, $metadata); + $this->assertEquals($collectionOne, $metadata[$classOne]); + $this->assertEquals($collectionTwo, $metadata[$classTwo]); } public function testItRebuildsFromTheLiveSourceEvenWhenTheResolvedFactoryIsTheDumpedDecorator(): void @@ -110,7 +116,7 @@ public function testItRebuildsFromTheLiveSourceEvenWhenTheResolvedFactoryIsTheDu $dumped = $this->readDump(); - $this->assertEquals($fresh, $dumped[$class]); + $this->assertEquals($fresh, $dumped['metadata'][$class]); } private function runDump(): PendingCommand diff --git a/src/Laravel/Tests/Metadata/DumpedMetadataMigrationStalenessTest.php b/src/Laravel/Tests/Metadata/DumpedMetadataMigrationStalenessTest.php new file mode 100644 index 00000000000..267119e250e --- /dev/null +++ b/src/Laravel/Tests/Metadata/DumpedMetadataMigrationStalenessTest.php @@ -0,0 +1,89 @@ + + * + * 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\Eloquent\Metadata\ModelMetadata; +use ApiPlatform\Laravel\Metadata\MetadataDumpFingerprint; +use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; +use Illuminate\Database\Events\MigrationsEnded; +use Orchestra\Testbench\Concerns\WithWorkbench; +use Orchestra\Testbench\TestCase; +use Psr\Log\LoggerInterface; + +class DumpedMetadataMigrationStalenessTest 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_migrate_dump_').'.meta'; + $this->writeDump('placeholder-fingerprint'); + + 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 testItWarnsWhenTheSchemaFingerprintDiffersAfterMigration(): void + { + // The dump still carries the placeholder fingerprint, which cannot match the live schema. + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once())->method('warning'); + $this->app->instance(LoggerInterface::class, $logger); + + $this->app['events']->dispatch(new MigrationsEnded('up')); + } + + public function testItDoesNotWarnWhenTheSchemaFingerprintMatches(): void + { + $current = MetadataDumpFingerprint::schema( + $this->app->make(ResourceNameCollectionFactoryInterface::class)->create(), + $this->app->make(ModelMetadata::class), + ); + $this->writeDump($current); + + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->never())->method('warning'); + $this->app->instance(LoggerInterface::class, $logger); + + $this->app['events']->dispatch(new MigrationsEnded('up')); + } + + private function writeDump(string $schemaFingerprint): void + { + file_put_contents($this->dumpPath, serialize([ + 'version' => MetadataDumpFingerprint::VERSION, + 'resources_fingerprint' => '', + 'schema_fingerprint' => $schemaFingerprint, + 'metadata' => [self::RESOURCE_CLASS => null], + ])); + } +} diff --git a/src/Laravel/Tests/Unit/Metadata/DumpedResourceCollectionMetadataFactoryTest.php b/src/Laravel/Tests/Unit/Metadata/DumpedResourceCollectionMetadataFactoryTest.php index 4ca95f5d293..a4b8f0fa59a 100644 --- a/src/Laravel/Tests/Unit/Metadata/DumpedResourceCollectionMetadataFactoryTest.php +++ b/src/Laravel/Tests/Unit/Metadata/DumpedResourceCollectionMetadataFactoryTest.php @@ -14,10 +14,12 @@ namespace ApiPlatform\Laravel\Tests\Unit\Metadata; use ApiPlatform\Laravel\Metadata\DumpedResourceCollectionMetadataFactory; +use ApiPlatform\Laravel\Metadata\MetadataDumpFingerprint; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; use Workbench\App\Models\Book; class DumpedResourceCollectionMetadataFactoryTest extends TestCase @@ -85,4 +87,69 @@ public function testItDelegatesWhenDumpFileIsAbsent(): void $this->assertSame($expected, $factory->create(Book::class)); } + + public function testItServesABareMapDumpWithoutAnyStalenessWarning(): void + { + // Old (pre-fingerprint) dump format: a plain map with no envelope. + $dumped = new ResourceMetadataCollection(Book::class, [new ApiResource(shortName: 'Book')]); + file_put_contents($this->dumpPath, serialize([Book::class => $dumped])); + + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->never())->method('warning'); + + $decorated = $this->createMock(ResourceMetadataCollectionFactoryInterface::class); + $decorated->expects($this->never())->method('create'); + + $factory = new DumpedResourceCollectionMetadataFactory($decorated, $this->dumpPath, $logger, [__DIR__]); + + $this->assertEquals($dumped, $factory->create(Book::class)); + } + + public function testItWarnsButStillServesTheDumpWhenResourceFilesChanged(): void + { + $dumped = new ResourceMetadataCollection(Book::class, [new ApiResource(shortName: 'Book')]); + file_put_contents($this->dumpPath, serialize($this->envelope([Book::class => $dumped], 'a-stale-fingerprint'))); + + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once())->method('warning'); + + $decorated = $this->createMock(ResourceMetadataCollectionFactoryInterface::class); + $decorated->expects($this->never())->method('create'); + + $factory = new DumpedResourceCollectionMetadataFactory($decorated, $this->dumpPath, $logger, [__DIR__]); + + // Dump is still served despite the staleness warning (no-DB boot must keep working). + $this->assertEquals($dumped, $factory->create(Book::class)); + } + + public function testItDoesNotWarnWhenTheResourceFingerprintMatches(): void + { + $dumped = new ResourceMetadataCollection(Book::class, [new ApiResource(shortName: 'Book')]); + $fingerprint = MetadataDumpFingerprint::resources([__DIR__]); + file_put_contents($this->dumpPath, serialize($this->envelope([Book::class => $dumped], $fingerprint))); + + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->never())->method('warning'); + + $decorated = $this->createStub(ResourceMetadataCollectionFactoryInterface::class); + + $factory = new DumpedResourceCollectionMetadataFactory($decorated, $this->dumpPath, $logger, [__DIR__]); + + $this->assertEquals($dumped, $factory->create(Book::class)); + } + + /** + * @param array $metadata + * + * @return array{version: int, resources_fingerprint: string, schema_fingerprint: string, metadata: array} + */ + private function envelope(array $metadata, string $resourcesFingerprint): array + { + return [ + 'version' => MetadataDumpFingerprint::VERSION, + 'resources_fingerprint' => $resourcesFingerprint, + 'schema_fingerprint' => '', + 'metadata' => $metadata, + ]; + } } diff --git a/src/Laravel/Tests/Unit/Metadata/MetadataDumpFingerprintTest.php b/src/Laravel/Tests/Unit/Metadata/MetadataDumpFingerprintTest.php new file mode 100644 index 00000000000..3cfd2231f56 --- /dev/null +++ b/src/Laravel/Tests/Unit/Metadata/MetadataDumpFingerprintTest.php @@ -0,0 +1,90 @@ + + * + * 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\Eloquent\Metadata\ModelMetadata; +use ApiPlatform\Laravel\Metadata\MetadataDumpFingerprint; +use PHPUnit\Framework\TestCase; + +class MetadataDumpFingerprintTest extends TestCase +{ + private string $dir; + + protected function setUp(): void + { + $this->dir = sys_get_temp_dir().'/apip_fp_'.bin2hex(random_bytes(6)); + mkdir($this->dir.'/nested', 0o755, true); + file_put_contents($this->dir.'/A.php', 'dir.'/nested/B.php', 'dir.'/nested/B.php', $this->dir.'/A.php', $this->dir.'/nested', $this->dir] as $path) { + if (is_file($path)) { + unlink($path); + } elseif (is_dir($path)) { + @rmdir($path); + } + } + } + + public function testResourcesHashIsStableAcrossCalls(): void + { + $this->assertSame( + MetadataDumpFingerprint::resources([$this->dir]), + MetadataDumpFingerprint::resources([$this->dir]), + ); + } + + public function testResourcesHashChangesWhenAFileContentChanges(): void + { + $before = MetadataDumpFingerprint::resources([$this->dir]); + + file_put_contents($this->dir.'/A.php', 'assertNotSame($before, MetadataDumpFingerprint::resources([$this->dir])); + } + + public function testResourcesHashIgnoresNonPhpFiles(): void + { + $before = MetadataDumpFingerprint::resources([$this->dir]); + + file_put_contents($this->dir.'/notes.txt', 'not a resource'); + $after = MetadataDumpFingerprint::resources([$this->dir]); + unlink($this->dir.'/notes.txt'); + + $this->assertSame($before, $after); + } + + public function testResourcesHashSkipsMissingPaths(): void + { + $this->assertSame( + MetadataDumpFingerprint::resources([$this->dir]), + MetadataDumpFingerprint::resources([$this->dir, $this->dir.'/does-not-exist']), + ); + } + + public function testSchemaSkipsClassesThatAreNotEloquentModels(): void + { + // Non-model and non-existent classes must never reach the database introspection, + // so the signature stays empty-equivalent regardless of how many are passed. + $modelMetadata = new ModelMetadata(); + + $this->assertSame( + MetadataDumpFingerprint::schema([], $modelMetadata), + MetadataDumpFingerprint::schema([self::class, 'App\\Does\\Not\\Exist'], $modelMetadata), + ); + } +}