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
4 changes: 2 additions & 2 deletions src/Laravel/ApiPlatformDeferredProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ public function register(): void

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

$this->app->singleton(ResourceMetadataCollectionFactoryInterface::class, function (Application $app) {
$this->app->singleton('api_platform.metadata.resource_collection_factory.source', function (Application $app) {
/** @var ConfigRepository $config */
$config = $app['config'];
$formats = $config->get('api-platform.formats');
Expand Down Expand Up @@ -391,7 +391,7 @@ public function provides(): array
ParameterProvider::class,
FilterQueryExtension::class,
'filters',
ResourceMetadataCollectionFactoryInterface::class,
'api_platform.metadata.resource_collection_factory.source',
'api_platform.graphql.state_provider.parameter',
FieldsBuilderEnumInterface::class,
ExceptionHandlerInterface::class,
Expand Down
64 changes: 62 additions & 2 deletions src/Laravel/ApiPlatformProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -389,7 +392,7 @@ public function register(): void

// ObjectMapper metadata factory support
if (interface_exists(ObjectMapperInterface::class)) {
$this->app->extend(ResourceMetadataCollectionFactoryInterface::class, static function (ResourceMetadataCollectionFactoryInterface $inner, Application $app) {
$this->app->extend('api_platform.metadata.resource_collection_factory.source', static function (ResourceMetadataCollectionFactoryInterface $inner, Application $app) {
return new ObjectMapperMetadataCollectionFactory(
$inner,
$app->make(ObjectMapperMetadataFactoryInterface::class)
Expand All @@ -398,10 +401,31 @@ public function register(): void
}

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

// The metadata factory everyone injects: 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). The dump command gets
// the source factory directly (contextual binding below) so it never reads back its own dump.
$this->app->singleton(ResourceMetadataCollectionFactoryInterface::class, static function (Application $app) {
/** @var ConfigRepository $config */
$config = $app['config'];

$source = $app->make('api_platform.metadata.resource_collection_factory.source');

if (true === $config->get('app.debug')) {
return $source;
}

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

$this->app->when(Console\DumpMetadataCommand::class)
->needs(ResourceMetadataCollectionFactoryInterface::class)
->give(static fn (Application $app) => $app->make('api_platform.metadata.resource_collection_factory.source'));

$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 +1134,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 Expand Up @@ -1482,6 +1507,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]);
}
}
88 changes: 88 additions & 0 deletions src/Laravel/Console/DumpMetadataCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
<?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\Eloquent\Metadata\ModelMetadata;
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;
}

// The container hands this command the source factory (never the dumped layer), so building
// always reads the live source rather than a previously dumped (possibly stale) file.
$metadata = [];
foreach ($this->resourceNameCollectionFactory->create() as $resourceClass) {
$metadata[$resourceClass] = $this->resourceMetadataCollectionFactory->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;
}
}
101 changes: 101 additions & 0 deletions src/Laravel/Metadata/DumpedResourceCollectionMetadataFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
<?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;
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<class-string, ResourceMetadataCollection>|null
*/
private ?array $dumped = null;

/**
* @param list<string> $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);
}

/**
* @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]);
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]);
}
}
Loading
Loading