Skip to content

Commit 71918e4

Browse files
authored
fix(jsonschema): respect readableLink for resource-typed properties on non-resource parents (#8362)
1 parent 1d641cc commit 71918e4

2 files changed

Lines changed: 52 additions & 8 deletions

File tree

src/JsonSchema/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,9 +73,27 @@ public function create(string $resourceClass, string $property, array $options =
7373
// 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())
7474
$link = $isInput ? $propertyMetadata->isWritableLink() : ($propertyMetadata->isReadableLink() || false === $propertyMetadata->getGenId());
7575

76-
// on output a non-resource object is serialized by the standard object normalizer, which embeds related resources regardless of readableLink (see AbstractItemNormalizer::supportsNormalization())
76+
// on output a non-resource object is serialized by the standard object normalizer, which embeds non-resource properties regardless of readableLink (see AbstractItemNormalizer::supportsNormalization())
77+
// For resource-typed properties however, the circular reference handler (see AbstractItemNormalizer::$defaultContext) may produce an IRI, so isReadableLink should determine the schema
7778
if (!$isInput && !$this->isResourceClass($resourceClass)) {
78-
$link = true;
79+
if (method_exists(PropertyInfoExtractor::class, 'getType')) {
80+
if (!$propertyMetadata->getNativeType()?->isSatisfiedBy(fn (Type $t) => $t instanceof ObjectType && $this->resourceClassResolver->isResourceClass($t->getClassName()))) {
81+
$link = true;
82+
}
83+
} else {
84+
$propertyTypeIsResource = false;
85+
foreach ($propertyMetadata->getBuiltinTypes() ?? [] as $builtinType) {
86+
$className = $builtinType->isCollection() ? ($builtinType->getCollectionValueTypes()[0] ?? null)?->getClassName() : $builtinType->getClassName();
87+
if ($className && $this->resourceClassResolver->isResourceClass($className)) {
88+
$propertyTypeIsResource = true;
89+
break;
90+
}
91+
}
92+
93+
if (!$propertyTypeIsResource) {
94+
$link = true;
95+
}
96+
}
7997
}
8098

8199
$propertySchema = $propertyMetadata->getSchema() ?? [];

src/JsonSchema/Tests/Metadata/Property/Factory/SchemaPropertyMetadataFactoryTest.php

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -207,11 +207,12 @@ public function testRelationWithGenIdFalseIsEmbeddedInOutputSchema(): void
207207
}
208208

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

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

233-
// defers to SchemaFactory ($ref to embedded subschema) instead of an iri-reference string
234+
$this->assertSame(['type' => 'string', 'format' => 'iri-reference', 'example' => 'https://example.com/'], $apiProperty->getSchema());
235+
}
236+
237+
/**
238+
* A relation on a non-resource parent where the property type is also not a resource must still be embedded
239+
* (UNKNOWN_TYPE → resolved as $ref by SchemaFactory). The standard object normalizer embeds non-resource
240+
* objects and isReadableLink is irrelevant here, so the schema defers to SchemaFactory.
241+
*/
242+
public function testNonResourceRelationOnNonResourceParentIsEmbeddedInOutputSchema(): void
243+
{
244+
if (!method_exists(PropertyInfoExtractor::class, 'getType')) { // @phpstan-ignore-line symfony/property-info 6.4 is still allowed and this may be true
245+
$this->markTestSkipped('This test only supports type-info component');
246+
}
247+
248+
$resourceClassResolver = $this->createMock(ResourceClassResolverInterface::class);
249+
// neither the parent nor the property type is a resource
250+
$resourceClassResolver->method('isResourceClass')->willReturn(false);
251+
252+
$apiProperty = (new ApiProperty(nativeType: Type::object(DummyWithEnum::class)))
253+
->withReadableLink(false);
254+
$decorated = $this->createMock(PropertyMetadataFactoryInterface::class);
255+
$decorated->expects($this->once())->method('create')->with(DummyWithEnum::class, 'nested')->willReturn($apiProperty);
256+
257+
$schemaPropertyMetadataFactory = new SchemaPropertyMetadataFactory($resourceClassResolver, $decorated);
258+
$apiProperty = $schemaPropertyMetadataFactory->create(DummyWithEnum::class, 'nested');
259+
234260
$this->assertSame(['type' => Schema::UNKNOWN_TYPE], $apiProperty->getSchema());
235261
}
236262

0 commit comments

Comments
 (0)