Skip to content

Commit 602b7eb

Browse files
committed
feat(laravel): boot without a database via dumped metadata
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
1 parent 134bb5c commit 602b7eb

7 files changed

Lines changed: 457 additions & 0 deletions

File tree

src/Laravel/ApiPlatformProvider.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@
9898
use ApiPlatform\Laravel\JsonApi\State\JsonApiProvider;
9999
use ApiPlatform\Laravel\Metadata\CachePropertyMetadataFactory;
100100
use ApiPlatform\Laravel\Metadata\CachePropertyNameCollectionMetadataFactory;
101+
use ApiPlatform\Laravel\Metadata\DumpedResourceCollectionMetadataFactory;
101102
use ApiPlatform\Laravel\Routing\IriConverter;
102103
use ApiPlatform\Laravel\Routing\Router as UrlGeneratorRouter;
103104
use ApiPlatform\Laravel\Routing\SkolemIriConverter;
@@ -402,6 +403,20 @@ public function register(): void
402403
return new Metadata\Resource\Factory\ParameterResourceMetadataCollectionFactory($inner, $app->make(ModelMetadata::class), new \Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter());
403404
});
404405

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) {
410+
/** @var ConfigRepository $config */
411+
$config = $app['config'];
412+
413+
if (true === $config->get('app.debug')) {
414+
return $inner;
415+
}
416+
417+
return new DumpedResourceCollectionMetadataFactory($inner, $config->get('api-platform.metadata_dump'));
418+
});
419+
405420
$this->app->singleton(OperationMetadataFactory::class, static function (Application $app) {
406421
return new OperationMetadataFactory($app->make(ResourceNameCollectionFactoryInterface::class), $app->make(ResourceMetadataCollectionFactoryInterface::class));
407422
});
@@ -1110,6 +1125,7 @@ public function register(): void
11101125
if ($this->app->runningInConsole()) {
11111126
$this->commands([
11121127
Console\InstallCommand::class,
1128+
Console\DumpMetadataCommand::class,
11131129
Console\Maker\MakeStateProcessorCommand::class,
11141130
Console\Maker\MakeStateProviderCommand::class,
11151131
Console\Maker\MakeFilterCommand::class,
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
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\Console;
15+
16+
use ApiPlatform\Laravel\Metadata\DumpedResourceCollectionMetadataFactory;
17+
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
18+
use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface;
19+
use Illuminate\Console\Command;
20+
use Symfony\Component\Console\Attribute\AsCommand;
21+
22+
#[AsCommand(name: 'api-platform:metadata:dump')]
23+
final class DumpMetadataCommand extends Command
24+
{
25+
/**
26+
* @var string
27+
*/
28+
protected $signature = 'api-platform:metadata:dump {--path= : Where to write the dumped metadata file (defaults to the api-platform.metadata_dump config value)}';
29+
30+
/**
31+
* @var string
32+
*/
33+
protected $description = 'Dump the resource metadata to a file so the app can boot without hitting the database';
34+
35+
public function __construct(
36+
private readonly ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory,
37+
private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory,
38+
) {
39+
parent::__construct();
40+
}
41+
42+
public function handle(): int
43+
{
44+
$path = $this->option('path') ?: config('api-platform.metadata_dump');
45+
46+
if (!\is_string($path) || '' === $path) {
47+
$this->error('No dump path configured. Pass --path or set the "api-platform.metadata_dump" config value.');
48+
49+
return self::FAILURE;
50+
}
51+
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+
58+
$metadata = [];
59+
foreach ($this->resourceNameCollectionFactory->create() as $resourceClass) {
60+
$metadata[$resourceClass] = $factory->create($resourceClass);
61+
}
62+
63+
$directory = \dirname($path);
64+
if (!is_dir($directory) && !mkdir($directory, 0o755, true) && !is_dir($directory)) {
65+
$this->error(\sprintf('Unable to create directory "%s".', $directory));
66+
67+
return self::FAILURE;
68+
}
69+
70+
if (false === file_put_contents($path, serialize($metadata))) {
71+
$this->error(\sprintf('Unable to write the metadata dump to "%s".', $path));
72+
73+
return self::FAILURE;
74+
}
75+
76+
$this->info(\sprintf('Dumped metadata for %d resource(s) to "%s".', \count($metadata), $path));
77+
78+
return self::SUCCESS;
79+
}
80+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
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\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
17+
use ApiPlatform\Metadata\Resource\ResourceMetadataCollection;
18+
19+
/**
20+
* Serves the resource metadata from a file dumped by api-platform:metadata:dump, bypassing the
21+
* database introspection that happens while building the collection. Delegates to the decorated
22+
* factory for any resource missing from the dump (or when no dump file exists).
23+
*/
24+
final class DumpedResourceCollectionMetadataFactory implements ResourceMetadataCollectionFactoryInterface
25+
{
26+
/**
27+
* @var array<class-string, ResourceMetadataCollection>|null
28+
*/
29+
private ?array $dumped = null;
30+
31+
public function __construct(
32+
private readonly ResourceMetadataCollectionFactoryInterface $decorated,
33+
private readonly ?string $dumpPath,
34+
) {
35+
}
36+
37+
public function create(string $resourceClass): ResourceMetadataCollection
38+
{
39+
$dumped = $this->load();
40+
41+
return $dumped[$resourceClass] ?? $this->decorated->create($resourceClass);
42+
}
43+
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+
53+
/**
54+
* @return array<class-string, ResourceMetadataCollection>
55+
*/
56+
private function load(): array
57+
{
58+
if (null !== $this->dumped) {
59+
return $this->dumped;
60+
}
61+
62+
if (null === $this->dumpPath || !is_file($this->dumpPath)) {
63+
return $this->dumped = [];
64+
}
65+
66+
$contents = file_get_contents($this->dumpPath);
67+
if (false === $contents) {
68+
return $this->dumped = [];
69+
}
70+
71+
$data = unserialize($contents, ['allowed_classes' => true]);
72+
73+
return $this->dumped = \is_array($data) ? $data : [];
74+
}
75+
}
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
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\Tests\Console;
15+
16+
use ApiPlatform\Laravel\Metadata\DumpedResourceCollectionMetadataFactory;
17+
use ApiPlatform\Metadata\ApiResource;
18+
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
19+
use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface;
20+
use ApiPlatform\Metadata\Resource\ResourceMetadataCollection;
21+
use ApiPlatform\Metadata\Resource\ResourceNameCollection;
22+
use Illuminate\Console\Command;
23+
use Orchestra\Testbench\Concerns\WithWorkbench;
24+
use Orchestra\Testbench\TestCase;
25+
26+
class DumpMetadataCommandTest extends TestCase
27+
{
28+
use WithWorkbench;
29+
30+
private string $dumpPath;
31+
32+
protected function setUp(): void
33+
{
34+
parent::setUp();
35+
36+
$this->dumpPath = tempnam(sys_get_temp_dir(), 'apip_dump_cmd_').'.meta';
37+
@unlink($this->dumpPath);
38+
}
39+
40+
protected function tearDown(): void
41+
{
42+
if (is_file($this->dumpPath)) {
43+
unlink($this->dumpPath);
44+
}
45+
46+
parent::tearDown();
47+
}
48+
49+
public function testItDumpsTheResourceMetadataCollectionMapToTheGivenFile(): void
50+
{
51+
$classOne = 'App\\Resource\\One';
52+
$classTwo = 'App\\Resource\\Two';
53+
54+
$collectionOne = new ResourceMetadataCollection($classOne, [new ApiResource(shortName: 'One')]);
55+
$collectionTwo = new ResourceMetadataCollection($classTwo, [new ApiResource(shortName: 'Two')]);
56+
57+
$nameFactory = $this->createStub(ResourceNameCollectionFactoryInterface::class);
58+
$nameFactory->method('create')->willReturn(new ResourceNameCollection([$classOne, $classTwo]));
59+
60+
$metadataFactory = $this->createStub(ResourceMetadataCollectionFactoryInterface::class);
61+
$metadataFactory->method('create')->willReturnCallback(static fn (string $class): ResourceMetadataCollection => match ($class) {
62+
$classOne => $collectionOne,
63+
$classTwo => $collectionTwo,
64+
});
65+
66+
$this->app->instance(ResourceNameCollectionFactoryInterface::class, $nameFactory);
67+
$this->app->instance(ResourceMetadataCollectionFactoryInterface::class, $metadataFactory);
68+
69+
$this->artisan('api-platform:metadata:dump', ['--path' => $this->dumpPath])
70+
->assertExitCode(Command::SUCCESS);
71+
72+
$this->assertFileExists($this->dumpPath);
73+
74+
$dumped = unserialize(file_get_contents($this->dumpPath), ['allowed_classes' => true]);
75+
76+
$this->assertIsArray($dumped);
77+
$this->assertArrayHasKey($classOne, $dumped);
78+
$this->assertArrayHasKey($classTwo, $dumped);
79+
$this->assertEquals($collectionOne, $dumped[$classOne]);
80+
$this->assertEquals($collectionTwo, $dumped[$classTwo]);
81+
}
82+
83+
public function testItRebuildsFromTheLiveSourceEvenWhenTheResolvedFactoryIsTheDumpedDecorator(): void
84+
{
85+
$class = 'App\\Resource\\Fresh';
86+
87+
$fresh = new ResourceMetadataCollection($class, [new ApiResource(shortName: 'Fresh')]);
88+
$stale = new ResourceMetadataCollection($class, [new ApiResource(shortName: 'Stale')]);
89+
90+
// Simulate an already-present (stale) dump on disk.
91+
file_put_contents($this->dumpPath, serialize([$class => $stale]));
92+
93+
$nameFactory = $this->createStub(ResourceNameCollectionFactoryInterface::class);
94+
$nameFactory->method('create')->willReturn(new ResourceNameCollection([$class]));
95+
96+
$live = $this->createStub(ResourceMetadataCollectionFactoryInterface::class);
97+
$live->method('create')->willReturn($fresh);
98+
99+
// The resolved factory is a DumpedResourceCollectionMetadataFactory pointing at the stale file.
100+
$dumpedFactory = new DumpedResourceCollectionMetadataFactory($live, $this->dumpPath);
101+
102+
$this->app->instance(ResourceNameCollectionFactoryInterface::class, $nameFactory);
103+
$this->app->instance(ResourceMetadataCollectionFactoryInterface::class, $dumpedFactory);
104+
105+
$this->artisan('api-platform:metadata:dump', ['--path' => $this->dumpPath])
106+
->assertExitCode(Command::SUCCESS);
107+
108+
$dumped = unserialize(file_get_contents($this->dumpPath), ['allowed_classes' => true]);
109+
110+
$this->assertEquals($fresh, $dumped[$class]);
111+
}
112+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
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\Tests\Metadata;
15+
16+
use ApiPlatform\Laravel\Metadata\DumpedResourceCollectionMetadataFactory;
17+
use ApiPlatform\Metadata\ApiResource;
18+
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
19+
use ApiPlatform\Metadata\Resource\ResourceMetadataCollection;
20+
use Orchestra\Testbench\Concerns\WithWorkbench;
21+
use Orchestra\Testbench\TestCase;
22+
23+
class DumpedMetadataBootTest extends TestCase
24+
{
25+
use WithWorkbench;
26+
27+
private const RESOURCE_CLASS = 'App\\NotAnEloquentModel';
28+
29+
private string $dumpPath;
30+
31+
protected function setUp(): void
32+
{
33+
$this->dumpPath = tempnam(sys_get_temp_dir(), 'apip_boot_dump_').'.meta';
34+
35+
$dumped = new ResourceMetadataCollection(self::RESOURCE_CLASS, [new ApiResource(shortName: 'FromDump')]);
36+
file_put_contents($this->dumpPath, serialize([self::RESOURCE_CLASS => $dumped]));
37+
38+
parent::setUp();
39+
}
40+
41+
protected function tearDown(): void
42+
{
43+
if (is_file($this->dumpPath)) {
44+
unlink($this->dumpPath);
45+
}
46+
47+
parent::tearDown();
48+
}
49+
50+
protected function defineEnvironment($app): void
51+
{
52+
$app['config']->set('app.debug', false);
53+
$app['config']->set('api-platform.metadata_dump', $this->dumpPath);
54+
}
55+
56+
public function testItServesMetadataFromTheDumpWithoutHittingTheDatabase(): void
57+
{
58+
$factory = $this->app->make(ResourceMetadataCollectionFactoryInterface::class);
59+
60+
$this->assertInstanceOf(DumpedResourceCollectionMetadataFactory::class, $factory);
61+
62+
// The class is not a real Eloquent model; if the dump were not consulted the inner
63+
// factory chain would try to introspect a non-existent model/table.
64+
$metadata = $factory->create(self::RESOURCE_CLASS);
65+
66+
$this->assertCount(1, $metadata);
67+
$this->assertSame('FromDump', $metadata[0]->getShortName());
68+
}
69+
70+
public function testItIsNotWrappedWhenDebugIsEnabled(): void
71+
{
72+
$this->app['config']->set('app.debug', true);
73+
$this->app->forgetInstance(ResourceMetadataCollectionFactoryInterface::class);
74+
75+
$factory = $this->app->make(ResourceMetadataCollectionFactoryInterface::class);
76+
77+
$this->assertNotInstanceOf(DumpedResourceCollectionMetadataFactory::class, $factory);
78+
}
79+
}

0 commit comments

Comments
 (0)