Skip to content

Commit 9e18fe0

Browse files
fix(jsonschema): embed relations of non-resource objects in output schema (#8294)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 20baa61 commit 9e18fe0

2 files changed

Lines changed: 58 additions & 0 deletions

File tree

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,11 @@ 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())
77+
if (!$isInput && !$this->isResourceClass($resourceClass)) {
78+
$link = true;
79+
}
80+
7681
$propertySchema = $propertyMetadata->getSchema() ?? [];
7782

7883
if (null !== $propertyMetadata->getUriTemplate() || (!\array_key_exists('readOnly', $propertySchema) && false === $propertyMetadata->isWritable() && !$propertyMetadata->isInitializable())) {

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

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,59 @@ public function testRelationWithGenIdFalseIsEmbeddedInOutputSchema(): void
206206
$this->assertSame(['type' => Schema::UNKNOWN_TYPE], $apiProperty->getSchema());
207207
}
208208

209+
/**
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.
213+
*/
214+
public function testRelationOnNonResourceParentIsEmbeddedInOutputSchema(): void
215+
{
216+
if (!method_exists(PropertyInfoExtractor::class, 'getType')) { // @phpstan-ignore-line symfony/property-info 6.4 is still allowed and this may be true
217+
$this->markTestSkipped('This test only supports type-info component');
218+
}
219+
220+
$resourceClassResolver = $this->createMock(ResourceClassResolverInterface::class);
221+
// the parent (DummyWithEnum) is not a resource, the related class (Dummy) is
222+
$resourceClassResolver->method('isResourceClass')->willReturnCallback(static fn (string $class): bool => Dummy::class === $class);
223+
224+
// not a readable link, gen_id left to its default: the relation would normally be an iri-reference string
225+
$apiProperty = (new ApiProperty(nativeType: Type::object(Dummy::class)))
226+
->withReadableLink(false);
227+
$decorated = $this->createMock(PropertyMetadataFactoryInterface::class);
228+
$decorated->expects($this->once())->method('create')->with(DummyWithEnum::class, 'relatedDummy')->willReturn($apiProperty);
229+
230+
$schemaPropertyMetadataFactory = new SchemaPropertyMetadataFactory($resourceClassResolver, $decorated);
231+
$apiProperty = $schemaPropertyMetadataFactory->create(DummyWithEnum::class, 'relatedDummy');
232+
233+
// defers to SchemaFactory ($ref to embedded subschema) instead of an iri-reference string
234+
$this->assertSame(['type' => Schema::UNKNOWN_TYPE], $apiProperty->getSchema());
235+
}
236+
237+
/**
238+
* Counterpart to the non-resource case: a non-readable-link relation on a *resource* parent still follows
239+
* readableLink and stays an iri-reference string — the non-resource guard must not widen to resources.
240+
*/
241+
public function testRelationOnResourceParentStaysIriReference(): void
242+
{
243+
if (!method_exists(PropertyInfoExtractor::class, 'getType')) { // @phpstan-ignore-line symfony/property-info 6.4 is still allowed and this may be true
244+
$this->markTestSkipped('This test only supports type-info component');
245+
}
246+
247+
$resourceClassResolver = $this->createMock(ResourceClassResolverInterface::class);
248+
// both the parent and the related class are resources
249+
$resourceClassResolver->method('isResourceClass')->willReturn(true);
250+
251+
$apiProperty = (new ApiProperty(nativeType: Type::object(Dummy::class)))
252+
->withReadableLink(false);
253+
$decorated = $this->createMock(PropertyMetadataFactoryInterface::class);
254+
$decorated->expects($this->once())->method('create')->with(Dummy::class, 'relatedDummy')->willReturn($apiProperty);
255+
256+
$schemaPropertyMetadataFactory = new SchemaPropertyMetadataFactory($resourceClassResolver, $decorated);
257+
$apiProperty = $schemaPropertyMetadataFactory->create(Dummy::class, 'relatedDummy');
258+
259+
$this->assertSame(['type' => 'string', 'format' => 'iri-reference', 'example' => 'https://example.com/'], $apiProperty->getSchema());
260+
}
261+
209262
public function testMixed(): void
210263
{
211264
if (!method_exists(PropertyInfoExtractor::class, 'getType')) { // @phpstan-ignore-line symfony/property-info 6.4 is still allowed and this may be true

0 commit comments

Comments
 (0)