Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,27 @@ public function create(string $resourceClass, string $property, array $options =
// on output the serializer embeds the relation as soon as gen_id is false, even when it is not a readable link (see AbstractItemNormalizer::normalizeRelation())
$link = $isInput ? $propertyMetadata->isWritableLink() : ($propertyMetadata->isReadableLink() || false === $propertyMetadata->getGenId());

// on output a non-resource object is serialized by the standard object normalizer, which embeds related resources regardless of readableLink (see AbstractItemNormalizer::supportsNormalization())
// on output a non-resource object is serialized by the standard object normalizer, which embeds non-resource properties regardless of readableLink (see AbstractItemNormalizer::supportsNormalization())
// For resource-typed properties however, the circular reference handler (see AbstractItemNormalizer::$defaultContext) may produce an IRI, so isReadableLink should determine the schema
if (!$isInput && !$this->isResourceClass($resourceClass)) {
$link = true;
if (method_exists(PropertyInfoExtractor::class, 'getType')) {
if (!$propertyMetadata->getNativeType()?->isSatisfiedBy(fn (Type $t) => $t instanceof ObjectType && $this->resourceClassResolver->isResourceClass($t->getClassName()))) {
$link = true;
}
} else {
$propertyTypeIsResource = false;
foreach ($propertyMetadata->getBuiltinTypes() ?? [] as $builtinType) {
$className = $builtinType->isCollection() ? ($builtinType->getCollectionValueTypes()[0] ?? null)?->getClassName() : $builtinType->getClassName();
if ($className && $this->resourceClassResolver->isResourceClass($className)) {
$propertyTypeIsResource = true;
break;
}
}

if (!$propertyTypeIsResource) {
$link = true;
}
}
}

$propertySchema = $propertyMetadata->getSchema() ?? [];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -207,11 +207,12 @@ public function testRelationWithGenIdFalseIsEmbeddedInOutputSchema(): void
}

/**
* A relation borne by a non-resource object (e.g. a raw Doctrine entity embedded as a readable link)
* is serialized by the standard object normalizer, which embeds the related resource regardless of its
* readableLink/genId. The output schema must embed it too, otherwise a strict client rejects the payload.
* A resource-typed relation on a non-resource parent with readableLink=false must be an iri-reference in the
* output schema. The circular reference handler (see AbstractItemNormalizer::$defaultContext) converts the related
* resource to an IRI when a cycle is detected (e.g. A → B[] → A), so isReadableLink must drive the schema
* instead of the old "non-resource parents always embed" heuristic.
*/
public function testRelationOnNonResourceParentIsEmbeddedInOutputSchema(): void
public function testRelationOnNonResourceParentFollowsReadableLinkInOutputSchema(): void
{
if (!method_exists(PropertyInfoExtractor::class, 'getType')) { // @phpstan-ignore-line symfony/property-info 6.4 is still allowed and this may be true
$this->markTestSkipped('This test only supports type-info component');
Expand All @@ -221,7 +222,7 @@ public function testRelationOnNonResourceParentIsEmbeddedInOutputSchema(): void
// the parent (DummyWithEnum) is not a resource, the related class (Dummy) is
$resourceClassResolver->method('isResourceClass')->willReturnCallback(static fn (string $class): bool => Dummy::class === $class);

// not a readable link, gen_id left to its default: the relation would normally be an iri-reference string
// readableLink=false: the relation should be an iri-reference string even on a non-resource parent
$apiProperty = (new ApiProperty(nativeType: Type::object(Dummy::class)))
->withReadableLink(false);
$decorated = $this->createMock(PropertyMetadataFactoryInterface::class);
Expand All @@ -230,7 +231,32 @@ public function testRelationOnNonResourceParentIsEmbeddedInOutputSchema(): void
$schemaPropertyMetadataFactory = new SchemaPropertyMetadataFactory($resourceClassResolver, $decorated);
$apiProperty = $schemaPropertyMetadataFactory->create(DummyWithEnum::class, 'relatedDummy');

// defers to SchemaFactory ($ref to embedded subschema) instead of an iri-reference string
$this->assertSame(['type' => 'string', 'format' => 'iri-reference', 'example' => 'https://example.com/'], $apiProperty->getSchema());
}

/**
* A relation on a non-resource parent where the property type is also not a resource must still be embedded
* (UNKNOWN_TYPE → resolved as $ref by SchemaFactory). The standard object normalizer embeds non-resource
* objects and isReadableLink is irrelevant here, so the schema defers to SchemaFactory.
*/
public function testNonResourceRelationOnNonResourceParentIsEmbeddedInOutputSchema(): void
{
if (!method_exists(PropertyInfoExtractor::class, 'getType')) { // @phpstan-ignore-line symfony/property-info 6.4 is still allowed and this may be true
$this->markTestSkipped('This test only supports type-info component');
}

$resourceClassResolver = $this->createMock(ResourceClassResolverInterface::class);
// neither the parent nor the property type is a resource
$resourceClassResolver->method('isResourceClass')->willReturn(false);

$apiProperty = (new ApiProperty(nativeType: Type::object(DummyWithEnum::class)))
->withReadableLink(false);
$decorated = $this->createMock(PropertyMetadataFactoryInterface::class);
$decorated->expects($this->once())->method('create')->with(DummyWithEnum::class, 'nested')->willReturn($apiProperty);

$schemaPropertyMetadataFactory = new SchemaPropertyMetadataFactory($resourceClassResolver, $decorated);
$apiProperty = $schemaPropertyMetadataFactory->create(DummyWithEnum::class, 'nested');

$this->assertSame(['type' => Schema::UNKNOWN_TYPE], $apiProperty->getSchema());
}

Expand Down
Loading