diff --git a/src/JsonSchema/SchemaFactory.php b/src/JsonSchema/SchemaFactory.php index 1157b458cb..d403def33f 100644 --- a/src/JsonSchema/SchemaFactory.php +++ b/src/JsonSchema/SchemaFactory.php @@ -348,7 +348,8 @@ private function buildPropertySchema(Schema $schema, string $definitionName, str } if (Schema::UNKNOWN_TYPE === $propertySchemaType) { - $propertySchema = []; + // the type is resolved to a reference below: drop the unresolved one, keep the rest of the property schema + $propertySchema = $this->withoutTypeExpression($propertySchema); } // property schema is created in SchemaPropertyMetadataFactory, but it cannot build resource reference ($ref) @@ -492,9 +493,9 @@ private function buildPropertySchema(Schema $schema, string $definitionName, str } if (($c = \count($refs)) > 1) { - $propertySchema = ['anyOf' => $refs]; + $propertySchema['anyOf'] = $refs; } elseif (1 === $c) { - $propertySchema = ['$ref' => $refs[0]['$ref']]; + $propertySchema['$ref'] = $refs[0]['$ref']; } } @@ -563,6 +564,17 @@ public function setSchemaFactory(SchemaFactoryInterface $schemaFactory): void $this->schemaFactory = $schemaFactory; } + /** + * Drops every key getSchemaValue() reads the type from, keeping the keys that document or + * constrain the property. + */ + private function withoutTypeExpression(array $schema): array + { + unset($schema['type'], $schema['items'], $schema['allOf'], $schema['anyOf'], $schema['oneOf']); + + return $schema; + } + private function getSchemaValue(array $schema, string $key): array|string|null { if (isset($schema['items'])) { diff --git a/src/JsonSchema/Tests/SchemaFactoryTest.php b/src/JsonSchema/Tests/SchemaFactoryTest.php index 10bee20288..f148471b84 100644 --- a/src/JsonSchema/Tests/SchemaFactoryTest.php +++ b/src/JsonSchema/Tests/SchemaFactoryTest.php @@ -40,6 +40,7 @@ use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; +use Symfony\Component\PropertyInfo\PropertyInfoExtractor; use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; use Symfony\Component\TypeInfo\Type; @@ -731,4 +732,130 @@ public function testBuildSchemaForAssociativeArray(): void $this->assertSame('object', $definitions[$rootDefinitionKey]['properties']['bar']['type']); $this->assertSame('string', $definitions[$rootDefinitionKey]['properties']['bar']['additionalProperties']); } + + /** + * @see https://github.com/api-platform/core/issues/8453 + */ + public function testBuildSchemaKeepsPropertyMetadataAlongsideASingleReference(): void + { + $properties = $this->buildPropertiesWithReference('child', (new ApiProperty()) + ->withNativeType(Type::object(GenericChild::class)) + ->withDescription('The child.') + ->withSchema(['description' => 'The child.', 'type' => Schema::UNKNOWN_TYPE])); + + $this->assertSame([ + 'description' => 'The child.', + '$ref' => '#/definitions/GenericChild', + ], $properties['child']->getArrayCopy()); + } + + /** + * @see https://github.com/api-platform/core/issues/8453 + */ + public function testBuildSchemaKeepsPropertyMetadataAlongsideANullableReference(): void + { + $properties = $this->buildPropertiesWithReference('child', (new ApiProperty()) + ->withNativeType(Type::nullable(Type::object(GenericChild::class))) + ->withDescription('The child.') + ->withDefault('default_child') + ->withExample('example_child') + ->withSchema(['description' => 'The child.', 'default' => 'default_child', 'example' => 'example_child', 'type' => Schema::UNKNOWN_TYPE])); + + $this->assertSame([ + 'description' => 'The child.', + 'default' => 'default_child', + 'example' => 'example_child', + 'anyOf' => [ + ['$ref' => '#/definitions/GenericChild'], + ['type' => 'null'], + ], + ], $properties['child']->getArrayCopy()); + } + + /** + * @see https://github.com/api-platform/core/issues/8453 + */ + public function testBuildSchemaKeepsPropertyMetadataAlongsideACollectionOfReferences(): void + { + $properties = $this->buildPropertiesWithReference('child', (new ApiProperty()) + ->withNativeType(Type::list(Type::object(GenericChild::class))) + ->withDescription('The children.') + ->withWritable(false) + ->withSchema(['description' => 'The children.', 'readOnly' => true, 'maxItems' => 1, 'type' => 'array', 'items' => ['type' => Schema::UNKNOWN_TYPE]])); + + $this->assertSame([ + 'description' => 'The children.', + 'readOnly' => true, + 'maxItems' => 1, + 'type' => 'array', + 'items' => ['$ref' => '#/definitions/GenericChild'], + ], $properties['child']->getArrayCopy()); + } + + /** + * An intersection carries its unresolved type in an allOf, which must not survive next to the references. + * + * @see https://github.com/api-platform/core/issues/8453 + */ + public function testBuildSchemaKeepsPropertyMetadataAlongsideAnIntersectionOfReferences(): void + { + $properties = $this->buildPropertiesWithReference('child', (new ApiProperty()) + ->withNativeType(Type::intersection(Type::object(GenericChild::class), Type::object(Serializable::class))) + ->withDescription('The child.') + ->withSchema(['description' => 'The child.', 'allOf' => [['type' => Schema::UNKNOWN_TYPE], ['type' => Schema::UNKNOWN_TYPE]]])); + + $this->assertSame([ + 'description' => 'The child.', + 'anyOf' => [ + ['$ref' => '#/definitions/GenericChild'], + ['$ref' => '#/definitions/Serializable'], + ], + ], $properties['child']->getArrayCopy()); + } + + /** + * Builds the schema of a non-resource class holding a single property typed after another class. + * + * @return array> + */ + private function buildPropertiesWithReference(string $propertyName, ApiProperty $propertyMetadata): array + { + 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'); + } + + $propertyNameCollectionFactory = $this->createStub(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactory->method('create')->willReturnCallback( + static fn (string $class): PropertyNameCollection => new PropertyNameCollection( + NotAResource::class === $class ? [$propertyName] : ['property'], + ), + ); + + $childPropertyMetadata = (new ApiProperty()) + ->withNativeType(Type::string()) + ->withReadable(true) + ->withSchema(['type' => 'string']); + + $propertyMetadataFactory = $this->createStub(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactory->method('create')->willReturnCallback( + static fn (string $class): ApiProperty => NotAResource::class === $class + ? $propertyMetadata->withReadable(true) + : $childPropertyMetadata, + ); + + $resourceClassResolver = $this->createStub(ResourceClassResolverInterface::class); + $resourceClassResolver->method('isResourceClass')->willReturn(false); + + $schemaFactory = new SchemaFactory( + resourceMetadataFactory: $this->createStub(ResourceMetadataCollectionFactoryInterface::class), + propertyNameCollectionFactory: $propertyNameCollectionFactory, + propertyMetadataFactory: $propertyMetadataFactory, + resourceClassResolver: $resourceClassResolver, + definitionNameFactory: new DefinitionNameFactory(), + ); + + $schema = $schemaFactory->buildSchema(NotAResource::class); + + return $schema->getDefinitions()[$schema->getRootDefinitionKey()]['properties']; + } }