diff --git a/src/Laravel/ApiPlatformProvider.php b/src/Laravel/ApiPlatformProvider.php index 889f7703b49..cbce51bfe1e 100644 --- a/src/Laravel/ApiPlatformProvider.php +++ b/src/Laravel/ApiPlatformProvider.php @@ -98,6 +98,8 @@ use ApiPlatform\Laravel\JsonApi\State\JsonApiProvider; 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; @@ -174,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; @@ -402,6 +405,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'), $app->make(LoggerInterface::class), $config->get('api-platform.resources') ?? []); + }); + $this->app->singleton(OperationMetadataFactory::class, static function (Application $app) { return new OperationMetadataFactory($app->make(ResourceNameCollectionFactoryInterface::class), $app->make(ResourceMetadataCollectionFactoryInterface::class)); }); @@ -1110,6 +1127,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, @@ -1482,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 new file mode 100644 index 00000000000..9dac11ddcf1 --- /dev/null +++ b/src/Laravel/Console/DumpMetadataCommand.php @@ -0,0 +1,93 @@ + + * + * 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\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; +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, + private readonly ModelMetadata $modelMetadata, + ) { + 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); + } + + // 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)); + + return self::FAILURE; + } + + if (false === file_put_contents($path, serialize($envelope))) { + $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..b995fe6d38a --- /dev/null +++ b/src/Laravel/Metadata/DumpedResourceCollectionMetadataFactory.php @@ -0,0 +1,110 @@ + + * + * 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; +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 +{ + /** + * @var array|null + */ + 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 = [], + ) { + } + + 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]); + 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; + } + + $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 new file mode 100644 index 00000000000..81096113616 --- /dev/null +++ b/src/Laravel/Tests/Console/DumpMetadataCommandTest.php @@ -0,0 +1,149 @@ + + * + * 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\Laravel\Metadata\MetadataDumpFingerprint; +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->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 + { + $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['metadata'][$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/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 new file mode 100644 index 00000000000..a4b8f0fa59a --- /dev/null +++ b/src/Laravel/Tests/Unit/Metadata/DumpedResourceCollectionMetadataFactoryTest.php @@ -0,0 +1,155 @@ + + * + * 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\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 +{ + 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)); + } + + 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), + ); + } +} 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,