Skip to content

Commit 7b0052b

Browse files
committed
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. The dump command no longer unwraps the resolved factory at runtime to reach the live source: the source metadata chain is bound under its own container key, the interface everyone injects resolves to the dumped wrapper, and the command receives the source chain via a contextual binding. Removes the DumpedResourceCollectionMetadataFactory::getDecorated() seam and guarantees the command cannot read back its own dump. Refs #8131, #8290
1 parent 62b74ce commit 7b0052b

9 files changed

Lines changed: 468 additions & 41 deletions

src/Laravel/ApiPlatformDeferredProvider.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,7 @@ public function register(): void
214214

215215
$this->autoconfigure($classes, ProviderInterface::class, $providers);
216216

217-
$this->app->singleton(ResourceMetadataCollectionFactoryInterface::class, function (Application $app) {
217+
$this->app->singleton('api_platform.metadata.resource_collection_factory.source', function (Application $app) {
218218
/** @var ConfigRepository $config */
219219
$config = $app['config'];
220220
$formats = $config->get('api-platform.formats');
@@ -391,7 +391,7 @@ public function provides(): array
391391
ParameterProvider::class,
392392
FilterQueryExtension::class,
393393
'filters',
394-
ResourceMetadataCollectionFactoryInterface::class,
394+
'api_platform.metadata.resource_collection_factory.source',
395395
'api_platform.graphql.state_provider.parameter',
396396
FieldsBuilderEnumInterface::class,
397397
ExceptionHandlerInterface::class,

src/Laravel/ApiPlatformProvider.php

Lines changed: 52 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@
9999
use ApiPlatform\Laravel\Metadata\CachePropertyMetadataFactory;
100100
use ApiPlatform\Laravel\Metadata\CachePropertyNameCollectionMetadataFactory;
101101
use ApiPlatform\Laravel\Metadata\DumpedResourceCollectionMetadataFactory;
102+
use ApiPlatform\Laravel\Metadata\MetadataDumpFingerprint;
102103
use ApiPlatform\Laravel\Routing\IriConverter;
103104
use ApiPlatform\Laravel\Routing\Router as UrlGeneratorRouter;
104105
use ApiPlatform\Laravel\Routing\SkolemIriConverter;
@@ -175,6 +176,7 @@
175176
use Http\Discovery\Psr17Factory;
176177
use Illuminate\Config\Repository as ConfigRepository;
177178
use Illuminate\Contracts\Foundation\Application;
179+
use Illuminate\Database\Events\MigrationsEnded;
178180
use Illuminate\Foundation\Http\Events\RequestHandled;
179181
use Illuminate\Routing\Router;
180182
use Illuminate\Support\Facades\Event;
@@ -390,7 +392,7 @@ public function register(): void
390392

391393
// ObjectMapper metadata factory support
392394
if (interface_exists(ObjectMapperInterface::class)) {
393-
$this->app->extend(ResourceMetadataCollectionFactoryInterface::class, static function (ResourceMetadataCollectionFactoryInterface $inner, Application $app) {
395+
$this->app->extend('api_platform.metadata.resource_collection_factory.source', static function (ResourceMetadataCollectionFactoryInterface $inner, Application $app) {
394396
return new ObjectMapperMetadataCollectionFactory(
395397
$inner,
396398
$app->make(ObjectMapperMetadataFactoryInterface::class)
@@ -399,24 +401,31 @@ public function register(): void
399401
}
400402

401403
// Parameter metadata factory with Laravel Eloquent support
402-
$this->app->extend(ResourceMetadataCollectionFactoryInterface::class, static function (ResourceMetadataCollectionFactoryInterface $inner, Application $app) {
404+
$this->app->extend('api_platform.metadata.resource_collection_factory.source', static function (ResourceMetadataCollectionFactoryInterface $inner, Application $app) {
403405
return new Metadata\Resource\Factory\ParameterResourceMetadataCollectionFactory($inner, $app->make(ModelMetadata::class), new \Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter());
404406
});
405407

406-
// Outermost: serve the resource metadata from a dumped file so the app can boot without a
407-
// live database. Skipped when APP_DEBUG is true so local development always recomputes fresh
408-
// metadata (mirroring the 'array' cache choice).
409-
$this->app->extend(ResourceMetadataCollectionFactoryInterface::class, static function (ResourceMetadataCollectionFactoryInterface $inner, Application $app) {
408+
// The metadata factory everyone injects: serve the resource metadata from a dumped file so the
409+
// app can boot without a live database. Skipped when APP_DEBUG is true so local development
410+
// always recomputes fresh metadata (mirroring the 'array' cache choice). The dump command gets
411+
// the source factory directly (contextual binding below) so it never reads back its own dump.
412+
$this->app->singleton(ResourceMetadataCollectionFactoryInterface::class, static function (Application $app) {
410413
/** @var ConfigRepository $config */
411414
$config = $app['config'];
412415

416+
$source = $app->make('api_platform.metadata.resource_collection_factory.source');
417+
413418
if (true === $config->get('app.debug')) {
414-
return $inner;
419+
return $source;
415420
}
416421

417-
return new DumpedResourceCollectionMetadataFactory($inner, $config->get('api-platform.metadata_dump'));
422+
return new DumpedResourceCollectionMetadataFactory($source, $config->get('api-platform.metadata_dump'), $app->make(LoggerInterface::class), $config->get('api-platform.resources') ?? []);
418423
});
419424

425+
$this->app->when(Console\DumpMetadataCommand::class)
426+
->needs(ResourceMetadataCollectionFactoryInterface::class)
427+
->give(static fn (Application $app) => $app->make('api_platform.metadata.resource_collection_factory.source'));
428+
420429
$this->app->singleton(OperationMetadataFactory::class, static function (Application $app) {
421430
return new OperationMetadataFactory($app->make(ResourceNameCollectionFactoryInterface::class), $app->make(ResourceMetadataCollectionFactoryInterface::class));
422431
});
@@ -1498,6 +1507,41 @@ public function boot(): void
14981507
$this->app->make(SkolemIriConverter::class)->reset();
14991508
});
15001509

1510+
// The schema can only be fingerprinted while the database is reachable, so detect schema
1511+
// drift against the dump right after a migration runs (warn-only — the dump is not rewritten).
1512+
$dumpPath = $config->get('api-platform.metadata_dump');
1513+
if (\is_string($dumpPath) && '' !== $dumpPath && true !== $config->get('app.debug')) {
1514+
Event::listen(MigrationsEnded::class, function () use ($dumpPath): void {
1515+
$this->warnIfDumpedSchemaIsStale($dumpPath);
1516+
});
1517+
}
1518+
15011519
$this->loadRoutesFrom(__DIR__.'/routes/api.php');
15021520
}
1521+
1522+
private function warnIfDumpedSchemaIsStale(string $dumpPath): void
1523+
{
1524+
if (!is_file($dumpPath)) {
1525+
return;
1526+
}
1527+
1528+
$contents = file_get_contents($dumpPath);
1529+
if (false === $contents) {
1530+
return;
1531+
}
1532+
1533+
$data = unserialize($contents, ['allowed_classes' => true]);
1534+
if (!\is_array($data) || !\is_string($data['schema_fingerprint'] ?? null)) {
1535+
return;
1536+
}
1537+
1538+
$resourceClasses = $this->app->make(ResourceNameCollectionFactoryInterface::class)->create();
1539+
$current = MetadataDumpFingerprint::schema($resourceClasses, $this->app->make(ModelMetadata::class));
1540+
1541+
if ($current === $data['schema_fingerprint']) {
1542+
return;
1543+
}
1544+
1545+
$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]);
1546+
}
15031547
}

src/Laravel/Console/DumpMetadataCommand.php

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@
1313

1414
namespace ApiPlatform\Laravel\Console;
1515

16-
use ApiPlatform\Laravel\Metadata\DumpedResourceCollectionMetadataFactory;
16+
use ApiPlatform\Laravel\Eloquent\Metadata\ModelMetadata;
17+
use ApiPlatform\Laravel\Metadata\MetadataDumpFingerprint;
1718
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
1819
use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface;
1920
use Illuminate\Console\Command;
@@ -35,6 +36,7 @@ final class DumpMetadataCommand extends Command
3536
public function __construct(
3637
private readonly ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory,
3738
private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory,
39+
private readonly ModelMetadata $modelMetadata,
3840
) {
3941
parent::__construct();
4042
}
@@ -49,25 +51,31 @@ public function handle(): int
4951
return self::FAILURE;
5052
}
5153

52-
// Always rebuild from the live source, never from a previously dumped (possibly stale) file.
53-
$factory = $this->resourceMetadataCollectionFactory;
54-
while ($factory instanceof DumpedResourceCollectionMetadataFactory) {
55-
$factory = $factory->getDecorated();
56-
}
57-
54+
// The container hands this command the source factory (never the dumped layer), so building
55+
// always reads the live source rather than a previously dumped (possibly stale) file.
5856
$metadata = [];
5957
foreach ($this->resourceNameCollectionFactory->create() as $resourceClass) {
60-
$metadata[$resourceClass] = $factory->create($resourceClass);
58+
$metadata[$resourceClass] = $this->resourceMetadataCollectionFactory->create($resourceClass);
6159
}
6260

61+
// Fingerprints let the app detect a stale dump later: resources at boot (no DB),
62+
// schema after a migration (DB up). Computed here while the live source is available.
63+
$resourcePaths = config('api-platform.resources') ?? [];
64+
$envelope = [
65+
'version' => MetadataDumpFingerprint::VERSION,
66+
'resources_fingerprint' => MetadataDumpFingerprint::resources($resourcePaths),
67+
'schema_fingerprint' => MetadataDumpFingerprint::schema(array_keys($metadata), $this->modelMetadata),
68+
'metadata' => $metadata,
69+
];
70+
6371
$directory = \dirname($path);
6472
if (!is_dir($directory) && !mkdir($directory, 0o755, true) && !is_dir($directory)) {
6573
$this->error(\sprintf('Unable to create directory "%s".', $directory));
6674

6775
return self::FAILURE;
6876
}
6977

70-
if (false === file_put_contents($path, serialize($metadata))) {
78+
if (false === file_put_contents($path, serialize($envelope))) {
7179
$this->error(\sprintf('Unable to write the metadata dump to "%s".', $path));
7280

7381
return self::FAILURE;

src/Laravel/Metadata/DumpedResourceCollectionMetadataFactory.php

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,17 @@
1515

1616
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
1717
use ApiPlatform\Metadata\Resource\ResourceMetadataCollection;
18+
use Psr\Log\LoggerInterface;
1819

1920
/**
2021
* Serves the resource metadata from a file dumped by api-platform:metadata:dump, bypassing the
2122
* database introspection that happens while building the collection. Delegates to the decorated
2223
* factory for any resource missing from the dump (or when no dump file exists).
24+
*
25+
* When the dump carries a resources fingerprint, it is checked against the current source files
26+
* once on load: a mismatch logs a warning (the dump is still served, so the no-database boot keeps
27+
* working) telling the operator to re-run api-platform:metadata:dump. Database schema drift cannot
28+
* be detected here without a connection; it is reported by the migrate listener instead.
2329
*/
2430
final class DumpedResourceCollectionMetadataFactory implements ResourceMetadataCollectionFactoryInterface
2531
{
@@ -28,9 +34,14 @@ final class DumpedResourceCollectionMetadataFactory implements ResourceMetadataC
2834
*/
2935
private ?array $dumped = null;
3036

37+
/**
38+
* @param list<string> $resourcePaths
39+
*/
3140
public function __construct(
3241
private readonly ResourceMetadataCollectionFactoryInterface $decorated,
3342
private readonly ?string $dumpPath,
43+
private readonly ?LoggerInterface $logger = null,
44+
private readonly array $resourcePaths = [],
3445
) {
3546
}
3647

@@ -41,15 +52,6 @@ public function create(string $resourceClass): ResourceMetadataCollection
4152
return $dumped[$resourceClass] ?? $this->decorated->create($resourceClass);
4253
}
4354

44-
/**
45-
* Exposes the decorated factory so the dump command can rebuild metadata from the live source
46-
* instead of reading back a previously dumped (possibly stale) file.
47-
*/
48-
public function getDecorated(): ResourceMetadataCollectionFactoryInterface
49-
{
50-
return $this->decorated;
51-
}
52-
5355
/**
5456
* @return array<class-string, ResourceMetadataCollection>
5557
*/
@@ -69,7 +71,31 @@ private function load(): array
6971
}
7072

7173
$data = unserialize($contents, ['allowed_classes' => true]);
74+
if (!\is_array($data)) {
75+
return $this->dumped = [];
76+
}
77+
78+
// An envelope (version >= 1) carries fingerprints; a bare map is an older dump with no
79+
// freshness information — serve it as-is without a staleness check.
80+
if (!isset($data['version'], $data['metadata']) || !\is_array($data['metadata'])) {
81+
return $this->dumped = $data;
82+
}
83+
84+
$this->warnIfResourcesChanged(\is_string($data['resources_fingerprint'] ?? null) ? $data['resources_fingerprint'] : null);
85+
86+
return $this->dumped = $data['metadata'];
87+
}
88+
89+
private function warnIfResourcesChanged(?string $dumpedFingerprint): void
90+
{
91+
if (null === $dumpedFingerprint || null === $this->logger || [] === $this->resourcePaths) {
92+
return;
93+
}
94+
95+
if (MetadataDumpFingerprint::resources($this->resourcePaths) === $dumpedFingerprint) {
96+
return;
97+
}
7298

73-
return $this->dumped = \is_array($data) ? $data : [];
99+
$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]);
74100
}
75101
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Laravel\Metadata;
15+
16+
use ApiPlatform\Laravel\Eloquent\Metadata\ModelMetadata;
17+
use Illuminate\Database\Eloquent\Model;
18+
19+
/**
20+
* Computes freshness fingerprints for the metadata dump written by api-platform:metadata:dump.
21+
*
22+
* Two independent axes, because the dump exists to let the app boot without a database:
23+
* - resources(): hashes the ApiResource source files, so it can run at boot with no DB;
24+
* - schema(): hashes the live Eloquent schema, so it can only run when a DB is reachable.
25+
*/
26+
final class MetadataDumpFingerprint
27+
{
28+
public const VERSION = 1;
29+
30+
/**
31+
* Content hash of every PHP file under the given resource paths.
32+
*
33+
* Content-based (not filemtime) on purpose: a committed or image-baked dump must stay valid
34+
* across `git clone` and `docker build`, neither of which preserves modification times.
35+
*
36+
* @param list<string> $paths
37+
*/
38+
public static function resources(array $paths): string
39+
{
40+
$hashes = [];
41+
foreach ($paths as $path) {
42+
if (is_file($path)) {
43+
$hashes[$path] = sha1_file($path) ?: '';
44+
continue;
45+
}
46+
47+
if (!is_dir($path)) {
48+
continue;
49+
}
50+
51+
$iterator = new \RecursiveIteratorIterator(
52+
new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS),
53+
);
54+
foreach ($iterator as $file) {
55+
if (!$file instanceof \SplFileInfo || 'php' !== $file->getExtension()) {
56+
continue;
57+
}
58+
59+
$pathname = $file->getPathname();
60+
$hashes[$pathname] = sha1_file($pathname) ?: '';
61+
}
62+
}
63+
64+
ksort($hashes);
65+
66+
return hash('xxh128', serialize($hashes));
67+
}
68+
69+
/**
70+
* Hash of the live Eloquent schema (columns + relations) for every model-backed resource.
71+
* Requires a database connection.
72+
*
73+
* @param iterable<class-string> $resourceClasses
74+
*/
75+
public static function schema(iterable $resourceClasses, ModelMetadata $modelMetadata): string
76+
{
77+
$signature = [];
78+
foreach ($resourceClasses as $resourceClass) {
79+
try {
80+
$model = (new \ReflectionClass($resourceClass))->newInstanceWithoutConstructor();
81+
} catch (\ReflectionException) {
82+
continue;
83+
}
84+
85+
if (!$model instanceof Model) {
86+
continue;
87+
}
88+
89+
$signature[$resourceClass] = [
90+
'attributes' => $modelMetadata->getAttributes($model),
91+
'relations' => $modelMetadata->getRelations($model),
92+
];
93+
}
94+
95+
ksort($signature);
96+
97+
return hash('xxh128', serialize($signature));
98+
}
99+
}

0 commit comments

Comments
 (0)