Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions src/Laravel/ApiPlatformProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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));
});
Expand Down Expand Up @@ -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,
Expand Down
80 changes: 80 additions & 0 deletions src/Laravel/Console/DumpMetadataCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* 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;
}
}
75 changes: 75 additions & 0 deletions src/Laravel/Metadata/DumpedResourceCollectionMetadataFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* 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<class-string, ResourceMetadataCollection>|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<class-string, ResourceMetadataCollection>
*/
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 : [];
}
}
112 changes: 112 additions & 0 deletions src/Laravel/Tests/Console/DumpMetadataCommandTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* 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 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,
});

$this->app->instance(ResourceNameCollectionFactoryInterface::class, $nameFactory);
$this->app->instance(ResourceMetadataCollectionFactoryInterface::class, $metadataFactory);

$this->artisan('api-platform:metadata:dump', ['--path' => $this->dumpPath])
->assertExitCode(Command::SUCCESS);

$this->assertFileExists($this->dumpPath);

$dumped = unserialize(file_get_contents($this->dumpPath), ['allowed_classes' => true]);

$this->assertIsArray($dumped);
$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->artisan('api-platform:metadata:dump', ['--path' => $this->dumpPath])
->assertExitCode(Command::SUCCESS);

$dumped = unserialize(file_get_contents($this->dumpPath), ['allowed_classes' => true]);

$this->assertEquals($fresh, $dumped[$class]);
}
}
79 changes: 79 additions & 0 deletions src/Laravel/Tests/Metadata/DumpedMetadataBootTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* 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);
}
}
Loading
Loading