diff --git a/src/JsonSchema/SchemaFactory.php b/src/JsonSchema/SchemaFactory.php index b1bc418b919..6593e985870 100644 --- a/src/JsonSchema/SchemaFactory.php +++ b/src/JsonSchema/SchemaFactory.php @@ -291,6 +291,9 @@ private function buildPropertySchema(Schema $schema, string $definitionName, str $propertySchema['items'] = 1 === \count($unionRefs) ? $unionRefs[0] : ['anyOf' => $unionRefs]; + if ($isNullable) { + $propertySchema['type'] = $this->withNullType($propertySchema['type']); + } } else { $refs = 1 === \count($unionRefs) ? [$unionRefs[0]] @@ -350,6 +353,9 @@ private function buildPropertySchema(Schema $schema, string $definitionName, str $propertySchema[$key] = []; } $propertySchema[$key] = ['$ref' => $subSchemaResult['$ref']]; + if ($isNullable) { + $propertySchema['type'] = $this->withNullType($propertySchema['type']); + } $refs = []; break; } @@ -434,6 +440,13 @@ private function withoutTypeExpression(array $schema): array return $schema; } + private function withNullType(array|string $type): array + { + $types = (array) $type; + + return \in_array('null', $types, true) ? $types : [...$types, 'null']; + } + private function getSchemaValue(array $schema, string $key): array|string|null { if (isset($schema['items'])) { diff --git a/src/JsonSchema/Tests/Fixtures/NullableObjectCollection.php b/src/JsonSchema/Tests/Fixtures/NullableObjectCollection.php new file mode 100644 index 00000000000..72bee457688 --- /dev/null +++ b/src/JsonSchema/Tests/Fixtures/NullableObjectCollection.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\JsonSchema\Tests\Fixtures; + +class NullableObjectCollection +{ + /** @var list|null */ + public ?array $optionalChildren = null; + + /** @var list */ + public array $requiredChildren = []; + + /** @var list|null */ + public ?array $optionalMixedChildren = null; +} diff --git a/src/JsonSchema/Tests/SchemaFactoryTest.php b/src/JsonSchema/Tests/SchemaFactoryTest.php index 992de62d19b..56093795b2c 100644 --- a/src/JsonSchema/Tests/SchemaFactoryTest.php +++ b/src/JsonSchema/Tests/SchemaFactoryTest.php @@ -24,6 +24,7 @@ use ApiPlatform\JsonSchema\Tests\Fixtures\GenericChild; use ApiPlatform\JsonSchema\Tests\Fixtures\NotAResource; use ApiPlatform\JsonSchema\Tests\Fixtures\NotAResourceWithUnionIntersectTypes; +use ApiPlatform\JsonSchema\Tests\Fixtures\NullableObjectCollection; use ApiPlatform\JsonSchema\Tests\Fixtures\Serializable; use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiResource; @@ -48,6 +49,55 @@ class SchemaFactoryTest extends TestCase { use ProphecyTrait; + public function testBuildSchemaKeepsNullOnNullableObjectCollections(): void + { + $child = Type::object(GenericChild::class); + $types = [ + 'optionalChildren' => Type::nullable(Type::list($child)), + 'requiredChildren' => Type::list($child), + 'optionalMixedChildren' => Type::nullable(Type::list(Type::union($child, Type::object(NotAResource::class)))), + ]; + + $propertyNameCollectionFactory = $this->createStub(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactory->method('create')->willReturnCallback( + static fn (string $resourceClass): PropertyNameCollection => new PropertyNameCollection(match ($resourceClass) { + NullableObjectCollection::class => array_keys($types), + GenericChild::class => ['property'], + default => [], + }) + ); + + $propertyMetadataFactory = $this->createStub(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactory->method('create')->willReturnCallback( + static fn (string $resourceClass, string $property): ApiProperty => GenericChild::class === $resourceClass + ? (new ApiProperty())->withNativeType(Type::string())->withReadable(true)->withSchema(['type' => 'string']) + : (new ApiProperty())->withNativeType($types[$property])->withReadable(true)->withSchema(['type' => Schema::UNKNOWN_TYPE]) + ); + + $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(), + ); + $resultSchema = $schemaFactory->buildSchema(NullableObjectCollection::class); + + $properties = $resultSchema->getDefinitions()[$resultSchema->getRootDefinitionKey()]['properties']; + + $this->assertSame(['array', 'null'], $properties['optionalChildren']['type']); + $this->assertSame(['$ref' => '#/definitions/GenericChild'], (array) $properties['optionalChildren']['items']); + + $this->assertSame('array', $properties['requiredChildren']['type']); + $this->assertSame(['$ref' => '#/definitions/GenericChild'], (array) $properties['requiredChildren']['items']); + + $this->assertSame(['array', 'null'], $properties['optionalMixedChildren']['type']); + $this->assertArrayHasKey('anyOf', (array) $properties['optionalMixedChildren']['items']); + } + public function testBuildSchemaForNonResourceClass(): void { $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class);