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
53 changes: 53 additions & 0 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 @@ -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));
});
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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]);
}
}
93 changes: 93 additions & 0 deletions src/Laravel/Console/DumpMetadataCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
<?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\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;
}
}
110 changes: 110 additions & 0 deletions src/Laravel/Metadata/DumpedResourceCollectionMetadataFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
<?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);
}

/**
* 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]);
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]);
}
}
99 changes: 99 additions & 0 deletions src/Laravel/Metadata/MetadataDumpFingerprint.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
<?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\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<string> $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<class-string> $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));
}
}
Loading
Loading