Skip to content

Commit 517f697

Browse files
committed
Merge 4.3
2 parents b0f6dbd + 8999b60 commit 517f697

14 files changed

Lines changed: 863 additions & 53 deletions

File tree

phpstan.neon.dist

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,6 @@ parameters:
4646
-
4747
message: '#Symfony\\Component\\PropertyInfo\\Type#'
4848
identifier: class.notFound
49-
# mcp/sdk 0.6 renamed Mcp\Schema\Resource to ResourceDefinition; Loader resolves at runtime, only one exists per installed version
50-
-
51-
message: '#class Mcp\\Schema\\(Resource|ResourceDefinition) not found#'
52-
identifier: class.notFound
53-
path: src/Mcp/Capability/Registry/Loader.php
5449
# False positives
5550
- message: '#Call to an undefined method Negotiation\\AcceptHeader::getType\(\).#'
5651
-

src/JsonApi/JsonSchema/SchemaFactory.php

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ final class SchemaFactory implements SchemaFactoryInterface, SchemaFactoryAwareI
6363
'format' => 'iri-reference',
6464
],
6565
],
66+
'required' => ['type', 'id'],
6667
];
6768
private const PROPERTY_PROPS = [
6869
'id' => [
@@ -258,7 +259,6 @@ public function buildSchema(string $className, string $format = 'jsonapi', strin
258259
unset($schema['type']);
259260

260261
$properties = $this->buildDefinitionPropertiesSchema($key, $className, $format, $type, $operation, $schema, []);
261-
$properties['data']['properties']['attributes']['$ref'] = $prefix.$key;
262262

263263
$properties['data'] = [
264264
'type' => 'array',
@@ -322,7 +322,10 @@ private function buildDefinitionPropertiesSchema(string $key, string $className,
322322

323323
$refs[$this->getSchemaUriPrefix($schema->getVersion()).$definitionName] = '$ref';
324324
}
325-
$relatedDefinitions[$propertyName] = array_flip($refs);
325+
// keep one entry per related definition: a polymorphic relation targets several resource classes, all of which may appear in "included"
326+
foreach (array_keys($refs) as $ref) {
327+
$relatedDefinitions[$ref] = ['$ref' => $ref];
328+
}
326329
if ($isOne) {
327330
$relationships[$propertyName]['properties']['data'] = [
328331
'oneOf' => [
@@ -347,9 +350,8 @@ private function buildDefinitionPropertiesSchema(string $key, string $className,
347350
$attributes[$propertyName] = $property;
348351
}
349352

350-
$currentRef = $this->getSchemaUriPrefix($schema->getVersion()).$schema->getRootDefinitionKey();
351353
$replacement = self::PROPERTY_PROPS;
352-
$replacement['attributes'] = ['$ref' => $currentRef];
354+
$replacement['attributes']['properties'] = $attributes;
353355

354356
$included = [];
355357
if (\count($relationships) > 0) {
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
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\JsonApi\Tests\Fixtures;
15+
16+
use ApiPlatform\Metadata\ApiResource;
17+
18+
/**
19+
* A second related resource used to exercise polymorphic (union-typed) relationships.
20+
*/
21+
#[ApiResource]
22+
class OtherRelatedDummy
23+
{
24+
private ?int $id = null;
25+
26+
public ?string $label = null;
27+
28+
public function getId(): ?int
29+
{
30+
return $this->id;
31+
}
32+
33+
public function setId(?int $id): void
34+
{
35+
$this->id = $id;
36+
}
37+
}

src/JsonApi/Tests/JsonSchema/SchemaFactoryTest.php

Lines changed: 198 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,12 @@
1515

1616
use ApiPlatform\JsonApi\JsonSchema\SchemaFactory;
1717
use ApiPlatform\JsonApi\Tests\Fixtures\Dummy;
18+
use ApiPlatform\JsonApi\Tests\Fixtures\OtherRelatedDummy;
19+
use ApiPlatform\JsonApi\Tests\Fixtures\RelatedDummy;
1820
use ApiPlatform\JsonSchema\DefinitionNameFactory;
1921
use ApiPlatform\JsonSchema\Schema;
2022
use ApiPlatform\JsonSchema\SchemaFactory as BaseSchemaFactory;
23+
use ApiPlatform\Metadata\ApiProperty;
2124
use ApiPlatform\Metadata\ApiResource;
2225
use ApiPlatform\Metadata\Get;
2326
use ApiPlatform\Metadata\GetCollection;
@@ -31,7 +34,9 @@
3134
use ApiPlatform\Metadata\Resource\ResourceMetadataCollection;
3235
use ApiPlatform\Metadata\ResourceClassResolverInterface;
3336
use PHPUnit\Framework\TestCase;
37+
use Prophecy\Argument;
3438
use Prophecy\PhpUnit\ProphecyTrait;
39+
use Symfony\Component\TypeInfo\Type;
3540

3641
class SchemaFactoryTest extends TestCase
3742
{
@@ -112,7 +117,8 @@ public function testHasRootDefinitionKeyBuildSchema(): void
112117
'type' => 'string',
113118
],
114119
'attributes' => [
115-
'$ref' => '#/definitions/Dummy',
120+
'type' => 'object',
121+
'properties' => [],
116122
],
117123
],
118124
'required' => [
@@ -155,7 +161,8 @@ public function testSchemaTypeBuildSchema(): void
155161
$this->assertArrayHasKey('data', $objectSchema['properties']);
156162

157163
$this->assertArrayHasKey('items', $objectSchema['properties']['data']);
158-
$this->assertArrayHasKey('$ref', $objectSchema['properties']['data']['items']['properties']['attributes']);
164+
$this->assertArrayNotHasKey('$ref', $objectSchema['properties']['data']['items']['properties']['attributes']);
165+
$this->assertSame('object', $objectSchema['properties']['data']['items']['properties']['attributes']['type']);
159166

160167
$properties = $objectSchema['properties'];
161168
$this->assertArrayHasKey('data', $properties);
@@ -199,4 +206,193 @@ public function testPostOutputSchemaRequiresId(): void
199206
$data = $definitions[$rootDefinitionKey]['properties']['data'];
200207
$this->assertSame(['type', 'id'], $data['required']);
201208
}
209+
210+
public function testRelationIsExcludedFromAttributes(): void
211+
{
212+
$schemaFactory = $this->buildSchemaFactoryWithRelation();
213+
$resultSchema = $schemaFactory->buildSchema(Dummy::class, 'jsonapi');
214+
215+
$definitions = $resultSchema->getDefinitions();
216+
$rootDefinitionKey = $resultSchema->getRootDefinitionKey();
217+
$dataProperties = $definitions[$rootDefinitionKey]['properties']['data']['properties'];
218+
219+
$this->assertArrayHasKey('attributes', $dataProperties);
220+
$this->assertArrayNotHasKey('$ref', $dataProperties['attributes']);
221+
$this->assertSame('object', $dataProperties['attributes']['type']);
222+
223+
$attributes = $dataProperties['attributes']['properties'];
224+
$this->assertArrayHasKey('name', $attributes);
225+
$this->assertArrayNotHasKey('relatedDummy', $attributes, 'relations must not be documented as attributes');
226+
227+
$this->assertArrayHasKey('_id', $attributes, 'id is exposed as _id in the JSON:API attributes');
228+
$this->assertArrayNotHasKey('id', $attributes);
229+
230+
$this->assertArrayHasKey('relationships', $dataProperties);
231+
$this->assertArrayHasKey('relatedDummy', $dataProperties['relationships']['properties']);
232+
}
233+
234+
public function testRelationshipLinkageRequiresTypeAndId(): void
235+
{
236+
$schemaFactory = $this->buildSchemaFactoryWithRelation();
237+
$resultSchema = $schemaFactory->buildSchema(Dummy::class, 'jsonapi');
238+
239+
$definitions = $resultSchema->getDefinitions();
240+
$rootDefinitionKey = $resultSchema->getRootDefinitionKey();
241+
$dataProperties = $definitions[$rootDefinitionKey]['properties']['data']['properties'];
242+
243+
// a resource identifier object MUST contain type and id, @see https://jsonapi.org/format/#document-resource-identifier-objects
244+
$linkage = $dataProperties['relationships']['properties']['relatedDummy']['properties']['data']['oneOf'][1];
245+
$this->assertSame('object', $linkage['type']);
246+
$this->assertSame(['type', 'id'], $linkage['required']);
247+
}
248+
249+
public function testIncludedListsAllPolymorphicRelationTargets(): void
250+
{
251+
$schemaFactory = $this->buildSchemaFactoryWithPolymorphicRelation();
252+
$resultSchema = $schemaFactory->buildSchema(Dummy::class, 'jsonapi');
253+
254+
$definitions = $resultSchema->getDefinitions();
255+
$rootDefinitionKey = $resultSchema->getRootDefinitionKey();
256+
$included = $definitions[$rootDefinitionKey]['properties']['included'];
257+
258+
$refs = array_column($included['items']['anyOf'], '$ref');
259+
$this->assertContains('#/definitions/RelatedDummy.jsonapi', $refs);
260+
$this->assertContains('#/definitions/OtherRelatedDummy.jsonapi', $refs, 'every target of a polymorphic relation must be listed in included');
261+
}
262+
263+
private function buildSchemaFactoryWithPolymorphicRelation(): SchemaFactory
264+
{
265+
$dummyOperation = (new Get())->withName('get')->withShortName('Dummy');
266+
$relatedOperation = (new Get())->withName('get')->withShortName('RelatedDummy');
267+
$otherRelatedOperation = (new Get())->withName('get')->withShortName('OtherRelatedDummy');
268+
269+
$resourceMetadataFactory = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class);
270+
$resourceMetadataFactory->create(Dummy::class)->willReturn(
271+
new ResourceMetadataCollection(Dummy::class, [
272+
(new ApiResource())->withOperations(new Operations(['get' => $dummyOperation])),
273+
])
274+
);
275+
$resourceMetadataFactory->create(RelatedDummy::class)->willReturn(
276+
new ResourceMetadataCollection(RelatedDummy::class, [
277+
(new ApiResource())->withOperations(new Operations(['get' => $relatedOperation])),
278+
])
279+
);
280+
$resourceMetadataFactory->create(OtherRelatedDummy::class)->willReturn(
281+
new ResourceMetadataCollection(OtherRelatedDummy::class, [
282+
(new ApiResource())->withOperations(new Operations(['get' => $otherRelatedOperation])),
283+
])
284+
);
285+
286+
$propertyNameCollectionFactory = $this->prophesize(PropertyNameCollectionFactoryInterface::class);
287+
$propertyNameCollectionFactory->create(Dummy::class, Argument::type('array'))->willReturn(new PropertyNameCollection(['id', 'name', 'relatedDummy']));
288+
$propertyNameCollectionFactory->create(RelatedDummy::class, Argument::type('array'))->willReturn(new PropertyNameCollection(['id', 'name']));
289+
$propertyNameCollectionFactory->create(OtherRelatedDummy::class, Argument::type('array'))->willReturn(new PropertyNameCollection(['id', 'label']));
290+
291+
$propertyMetadataFactory = $this->prophesize(PropertyMetadataFactoryInterface::class);
292+
$propertyMetadataFactory->create(Dummy::class, 'id', Argument::type('array'))->willReturn(
293+
(new ApiProperty())->withNativeType(Type::int())->withReadable(true)->withSchema(['type' => 'integer'])
294+
);
295+
$propertyMetadataFactory->create(Dummy::class, 'name', Argument::type('array'))->willReturn(
296+
(new ApiProperty())->withNativeType(Type::string())->withReadable(true)->withSchema(['type' => 'string'])
297+
);
298+
$propertyMetadataFactory->create(Dummy::class, 'relatedDummy', Argument::type('array'))->willReturn(
299+
(new ApiProperty())->withNativeType(Type::union(Type::object(RelatedDummy::class), Type::object(OtherRelatedDummy::class)))->withReadable(true)->withSchema(['type' => Schema::UNKNOWN_TYPE])
300+
);
301+
$propertyMetadataFactory->create(RelatedDummy::class, 'id', Argument::type('array'))->willReturn(
302+
(new ApiProperty())->withNativeType(Type::int())->withReadable(true)->withSchema(['type' => 'integer'])
303+
);
304+
$propertyMetadataFactory->create(RelatedDummy::class, 'name', Argument::type('array'))->willReturn(
305+
(new ApiProperty())->withNativeType(Type::string())->withReadable(true)->withSchema(['type' => 'string'])
306+
);
307+
$propertyMetadataFactory->create(OtherRelatedDummy::class, 'id', Argument::type('array'))->willReturn(
308+
(new ApiProperty())->withNativeType(Type::int())->withReadable(true)->withSchema(['type' => 'integer'])
309+
);
310+
$propertyMetadataFactory->create(OtherRelatedDummy::class, 'label', Argument::type('array'))->willReturn(
311+
(new ApiProperty())->withNativeType(Type::string())->withReadable(true)->withSchema(['type' => 'string'])
312+
);
313+
314+
$resourceClassResolver = $this->prophesize(ResourceClassResolverInterface::class);
315+
$resourceClassResolver->isResourceClass(Dummy::class)->willReturn(true);
316+
$resourceClassResolver->isResourceClass(RelatedDummy::class)->willReturn(true);
317+
$resourceClassResolver->isResourceClass(OtherRelatedDummy::class)->willReturn(true);
318+
319+
$definitionNameFactory = new DefinitionNameFactory(null);
320+
321+
$baseSchemaFactory = new BaseSchemaFactory(
322+
resourceMetadataFactory: $resourceMetadataFactory->reveal(),
323+
propertyNameCollectionFactory: $propertyNameCollectionFactory->reveal(),
324+
propertyMetadataFactory: $propertyMetadataFactory->reveal(),
325+
resourceClassResolver: $resourceClassResolver->reveal(),
326+
definitionNameFactory: $definitionNameFactory,
327+
);
328+
329+
return new SchemaFactory(
330+
schemaFactory: $baseSchemaFactory,
331+
propertyMetadataFactory: $propertyMetadataFactory->reveal(),
332+
resourceClassResolver: $resourceClassResolver->reveal(),
333+
resourceMetadataFactory: $resourceMetadataFactory->reveal(),
334+
definitionNameFactory: $definitionNameFactory,
335+
);
336+
}
337+
338+
private function buildSchemaFactoryWithRelation(): SchemaFactory
339+
{
340+
$dummyOperation = (new Get())->withName('get')->withShortName('Dummy');
341+
$relatedOperation = (new Get())->withName('get')->withShortName('RelatedDummy');
342+
343+
$resourceMetadataFactory = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class);
344+
$resourceMetadataFactory->create(Dummy::class)->willReturn(
345+
new ResourceMetadataCollection(Dummy::class, [
346+
(new ApiResource())->withOperations(new Operations(['get' => $dummyOperation])),
347+
])
348+
);
349+
$resourceMetadataFactory->create(RelatedDummy::class)->willReturn(
350+
new ResourceMetadataCollection(RelatedDummy::class, [
351+
(new ApiResource())->withOperations(new Operations(['get' => $relatedOperation])),
352+
])
353+
);
354+
355+
$propertyNameCollectionFactory = $this->prophesize(PropertyNameCollectionFactoryInterface::class);
356+
$propertyNameCollectionFactory->create(Dummy::class, Argument::type('array'))->willReturn(new PropertyNameCollection(['id', 'name', 'relatedDummy']));
357+
$propertyNameCollectionFactory->create(RelatedDummy::class, Argument::type('array'))->willReturn(new PropertyNameCollection(['id', 'name']));
358+
359+
$propertyMetadataFactory = $this->prophesize(PropertyMetadataFactoryInterface::class);
360+
$propertyMetadataFactory->create(Dummy::class, 'id', Argument::type('array'))->willReturn(
361+
(new ApiProperty())->withNativeType(Type::int())->withReadable(true)->withSchema(['type' => 'integer'])
362+
);
363+
$propertyMetadataFactory->create(Dummy::class, 'name', Argument::type('array'))->willReturn(
364+
(new ApiProperty())->withNativeType(Type::string())->withReadable(true)->withSchema(['type' => 'string'])
365+
);
366+
$propertyMetadataFactory->create(Dummy::class, 'relatedDummy', Argument::type('array'))->willReturn(
367+
(new ApiProperty())->withNativeType(Type::object(RelatedDummy::class))->withReadable(true)->withSchema(['type' => Schema::UNKNOWN_TYPE])
368+
);
369+
$propertyMetadataFactory->create(RelatedDummy::class, 'id', Argument::type('array'))->willReturn(
370+
(new ApiProperty())->withNativeType(Type::int())->withReadable(true)->withSchema(['type' => 'integer'])
371+
);
372+
$propertyMetadataFactory->create(RelatedDummy::class, 'name', Argument::type('array'))->willReturn(
373+
(new ApiProperty())->withNativeType(Type::string())->withReadable(true)->withSchema(['type' => 'string'])
374+
);
375+
376+
$resourceClassResolver = $this->prophesize(ResourceClassResolverInterface::class);
377+
$resourceClassResolver->isResourceClass(Dummy::class)->willReturn(true);
378+
$resourceClassResolver->isResourceClass(RelatedDummy::class)->willReturn(true);
379+
380+
$definitionNameFactory = new DefinitionNameFactory(null);
381+
382+
$baseSchemaFactory = new BaseSchemaFactory(
383+
resourceMetadataFactory: $resourceMetadataFactory->reveal(),
384+
propertyNameCollectionFactory: $propertyNameCollectionFactory->reveal(),
385+
propertyMetadataFactory: $propertyMetadataFactory->reveal(),
386+
resourceClassResolver: $resourceClassResolver->reveal(),
387+
definitionNameFactory: $definitionNameFactory,
388+
);
389+
390+
return new SchemaFactory(
391+
schemaFactory: $baseSchemaFactory,
392+
propertyMetadataFactory: $propertyMetadataFactory->reveal(),
393+
resourceClassResolver: $resourceClassResolver->reveal(),
394+
resourceMetadataFactory: $resourceMetadataFactory->reveal(),
395+
definitionNameFactory: $definitionNameFactory,
396+
);
397+
}
202398
}

src/Laravel/ApiPlatformProvider.php

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@
8888
use ApiPlatform\Laravel\Eloquent\Metadata\Factory\Property\EloquentPropertyMetadataFactory;
8989
use ApiPlatform\Laravel\Eloquent\Metadata\Factory\Property\EloquentPropertyNameCollectionMetadataFactory;
9090
use ApiPlatform\Laravel\Eloquent\Metadata\IdentifiersExtractor as EloquentIdentifiersExtractor;
91+
use ApiPlatform\Laravel\Eloquent\Metadata\MetadataDumpFingerprint;
9192
use ApiPlatform\Laravel\Eloquent\Metadata\ModelMetadata;
9293
use ApiPlatform\Laravel\Eloquent\Metadata\ResourceClassResolver as EloquentResourceClassResolver;
9394
use ApiPlatform\Laravel\Eloquent\PropertyAccess\PropertyAccessor as EloquentPropertyAccessor;
@@ -247,10 +248,38 @@ public function register(): void
247248
);
248249
});
249250

250-
$this->app->singleton(ModelMetadata::class, static function () {
251+
// Serve the Eloquent model metadata from a dumped file so the app can boot without a live
252+
// database. Skipped when APP_DEBUG is true so local development always introspects fresh.
253+
// The dump holds only attribute/relation arrays (plain scalars and class-strings), so it is
254+
// read back with allowed_classes disabled. The dump command gets a live, unseeded instance
255+
// (contextual binding below) so it never reads back its own dump.
256+
$this->app->singleton(ModelMetadata::class, static function (Application $app) {
257+
/** @var ConfigRepository $config */
258+
$config = $app['config'];
259+
$dumpPath = $config->get('api-platform.metadata_dump');
260+
261+
if (true !== $config->get('app.debug') && \is_string($dumpPath) && is_file($dumpPath)) {
262+
$contents = file_get_contents($dumpPath);
263+
if (false !== $contents) {
264+
$data = @unserialize($contents, ['allowed_classes' => false]);
265+
if (\is_array($data) && \is_array($data['attributes'] ?? null) && \is_array($data['relations'] ?? null)) {
266+
$fingerprint = MetadataDumpFingerprint::fromMigrations($app->databasePath('migrations'));
267+
if (($data['fingerprint'] ?? null) !== $fingerprint) {
268+
$app['log']->warning('The API Platform metadata dump is stale: migrations have changed since it was generated. Re-run "php artisan api-platform:metadata:dump".');
269+
}
270+
271+
return new ModelMetadata(attributes: $data['attributes'], relations: $data['relations']);
272+
}
273+
}
274+
}
275+
251276
return new ModelMetadata();
252277
});
253278

279+
$this->app->when(Console\DumpMetadataCommand::class)
280+
->needs(ModelMetadata::class)
281+
->give(static fn () => new ModelMetadata());
282+
254283
$this->app->bind(ClassMetadataFactoryInterface::class, ClassMetadataFactory::class);
255284
$this->app->singleton(ClassMetadataFactory::class, static function (Application $app) {
256285
/** @var ConfigRepository */
@@ -1191,6 +1220,7 @@ public function register(): void
11911220
if ($this->app->runningInConsole()) {
11921221
$this->commands([
11931222
Console\InstallCommand::class,
1223+
Console\DumpMetadataCommand::class,
11941224
Console\Maker\MakeStateProcessorCommand::class,
11951225
Console\Maker\MakeStateProviderCommand::class,
11961226
Console\Maker\MakeFilterCommand::class,

0 commit comments

Comments
 (0)