diff --git a/src/JsonSchema/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php b/src/JsonSchema/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php index 5b37ccd5fa..6e55fa4b46 100644 --- a/src/JsonSchema/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php +++ b/src/JsonSchema/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php @@ -73,6 +73,11 @@ 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()) + if (!$isInput && !$this->isResourceClass($resourceClass)) { + $link = true; + } + $propertySchema = $propertyMetadata->getSchema() ?? []; if (null !== $propertyMetadata->getUriTemplate() || (!\array_key_exists('readOnly', $propertySchema) && false === $propertyMetadata->isWritable() && !$propertyMetadata->isInitializable())) { diff --git a/src/JsonSchema/Tests/Metadata/Property/Factory/SchemaPropertyMetadataFactoryTest.php b/src/JsonSchema/Tests/Metadata/Property/Factory/SchemaPropertyMetadataFactoryTest.php index 74c302956b..ece12edc0a 100644 --- a/src/JsonSchema/Tests/Metadata/Property/Factory/SchemaPropertyMetadataFactoryTest.php +++ b/src/JsonSchema/Tests/Metadata/Property/Factory/SchemaPropertyMetadataFactoryTest.php @@ -206,6 +206,59 @@ public function testRelationWithGenIdFalseIsEmbeddedInOutputSchema(): void $this->assertSame(['type' => Schema::UNKNOWN_TYPE], $apiProperty->getSchema()); } + /** + * 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. + */ + public function testRelationOnNonResourceParentIsEmbeddedInOutputSchema(): 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); + // 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 + $apiProperty = (new ApiProperty(nativeType: Type::object(Dummy::class))) + ->withReadableLink(false); + $decorated = $this->createMock(PropertyMetadataFactoryInterface::class); + $decorated->expects($this->once())->method('create')->with(DummyWithEnum::class, 'relatedDummy')->willReturn($apiProperty); + + $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' => Schema::UNKNOWN_TYPE], $apiProperty->getSchema()); + } + + /** + * Counterpart to the non-resource case: a non-readable-link relation on a *resource* parent still follows + * readableLink and stays an iri-reference string — the non-resource guard must not widen to resources. + */ + public function testRelationOnResourceParentStaysIriReference(): 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); + // both the parent and the related class are resources + $resourceClassResolver->method('isResourceClass')->willReturn(true); + + $apiProperty = (new ApiProperty(nativeType: Type::object(Dummy::class))) + ->withReadableLink(false); + $decorated = $this->createMock(PropertyMetadataFactoryInterface::class); + $decorated->expects($this->once())->method('create')->with(Dummy::class, 'relatedDummy')->willReturn($apiProperty); + + $schemaPropertyMetadataFactory = new SchemaPropertyMetadataFactory($resourceClassResolver, $decorated); + $apiProperty = $schemaPropertyMetadataFactory->create(Dummy::class, 'relatedDummy'); + + $this->assertSame(['type' => 'string', 'format' => 'iri-reference', 'example' => 'https://example.com/'], $apiProperty->getSchema()); + } + public function testMixed(): void { if (!method_exists(PropertyInfoExtractor::class, 'getType')) { // @phpstan-ignore-line symfony/property-info 6.4 is still allowed and this may be true