From cc44c96eb81411a88d0859d0dcade90b39a9ddc1 Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 13 Nov 2026 14:00:22 +0100 Subject: [PATCH 1/2] fix(jsonschema): keep null on nullable collections --- src/JsonSchema/SchemaFactory.php | 18 +++++++ .../Fixtures/NullableObjectCollection.php | 26 ++++++++++ src/JsonSchema/Tests/SchemaFactoryTest.php | 49 +++++++++++++++++++ 3 files changed, 93 insertions(+) create mode 100644 src/JsonSchema/Tests/Fixtures/NullableObjectCollection.php diff --git a/src/JsonSchema/SchemaFactory.php b/src/JsonSchema/SchemaFactory.php index b1bc418b919..cd44888042f 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,18 @@ private function withoutTypeExpression(array $schema): array return $schema; } + /** + * @param string|list $type + * + * @return list + */ + 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..376d000e8f8 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,54 @@ class SchemaFactoryTest extends TestCase { use ProphecyTrait; + public function testBuildSchemaKeepsNullOnNullableObjectCollections(): void + { + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(NullableObjectCollection::class, Argument::cetera())->willReturn(new PropertyNameCollection(['optionalChildren', 'requiredChildren', 'optionalMixedChildren'])); + $propertyNameCollectionFactoryProphecy->create(GenericChild::class, Argument::cetera())->willReturn(new PropertyNameCollection(['property'])); + $propertyNameCollectionFactoryProphecy->create(NotAResource::class, Argument::cetera())->willReturn(new PropertyNameCollection([])); + + $child = Type::object(GenericChild::class); + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(NullableObjectCollection::class, 'optionalChildren', Argument::cetera())->willReturn( + (new ApiProperty())->withNativeType(Type::nullable(Type::list($child)))->withReadable(true)->withSchema(['type' => Schema::UNKNOWN_TYPE]) + ); + $propertyMetadataFactoryProphecy->create(NullableObjectCollection::class, 'requiredChildren', Argument::cetera())->willReturn( + (new ApiProperty())->withNativeType(Type::list($child))->withReadable(true)->withSchema(['type' => Schema::UNKNOWN_TYPE]) + ); + $propertyMetadataFactoryProphecy->create(NullableObjectCollection::class, 'optionalMixedChildren', Argument::cetera())->willReturn( + (new ApiProperty())->withNativeType(Type::nullable(Type::list(Type::union($child, Type::object(NotAResource::class)))))->withReadable(true)->withSchema(['type' => Schema::UNKNOWN_TYPE]) + ); + $propertyMetadataFactoryProphecy->create(GenericChild::class, 'property', Argument::cetera())->willReturn( + (new ApiProperty())->withNativeType(Type::string())->withReadable(true)->withSchema(['type' => 'string']) + ); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->isResourceClass(Argument::any())->willReturn(false); + + $schemaFactory = new SchemaFactory( + resourceMetadataFactory: $resourceMetadataFactoryProphecy->reveal(), + propertyNameCollectionFactory: $propertyNameCollectionFactoryProphecy->reveal(), + propertyMetadataFactory: $propertyMetadataFactoryProphecy->reveal(), + resourceClassResolver: $resourceClassResolverProphecy->reveal(), + 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); From d349ff0eb180303055c3c5e50f8c08d6e90585f9 Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 13 Nov 2026 14:04:02 +0100 Subject: [PATCH 2/2] test(jsonschema): drop prophecy from nullable test --- src/JsonSchema/SchemaFactory.php | 5 --- src/JsonSchema/Tests/SchemaFactoryTest.php | 49 +++++++++++----------- 2 files changed, 25 insertions(+), 29 deletions(-) diff --git a/src/JsonSchema/SchemaFactory.php b/src/JsonSchema/SchemaFactory.php index cd44888042f..6593e985870 100644 --- a/src/JsonSchema/SchemaFactory.php +++ b/src/JsonSchema/SchemaFactory.php @@ -440,11 +440,6 @@ private function withoutTypeExpression(array $schema): array return $schema; } - /** - * @param string|list $type - * - * @return list - */ private function withNullType(array|string $type): array { $types = (array) $type; diff --git a/src/JsonSchema/Tests/SchemaFactoryTest.php b/src/JsonSchema/Tests/SchemaFactoryTest.php index 376d000e8f8..56093795b2c 100644 --- a/src/JsonSchema/Tests/SchemaFactoryTest.php +++ b/src/JsonSchema/Tests/SchemaFactoryTest.php @@ -51,36 +51,37 @@ class SchemaFactoryTest extends TestCase public function testBuildSchemaKeepsNullOnNullableObjectCollections(): void { - $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); - - $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); - $propertyNameCollectionFactoryProphecy->create(NullableObjectCollection::class, Argument::cetera())->willReturn(new PropertyNameCollection(['optionalChildren', 'requiredChildren', 'optionalMixedChildren'])); - $propertyNameCollectionFactoryProphecy->create(GenericChild::class, Argument::cetera())->willReturn(new PropertyNameCollection(['property'])); - $propertyNameCollectionFactoryProphecy->create(NotAResource::class, Argument::cetera())->willReturn(new PropertyNameCollection([])); - $child = Type::object(GenericChild::class); - $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $propertyMetadataFactoryProphecy->create(NullableObjectCollection::class, 'optionalChildren', Argument::cetera())->willReturn( - (new ApiProperty())->withNativeType(Type::nullable(Type::list($child)))->withReadable(true)->withSchema(['type' => Schema::UNKNOWN_TYPE]) - ); - $propertyMetadataFactoryProphecy->create(NullableObjectCollection::class, 'requiredChildren', Argument::cetera())->willReturn( - (new ApiProperty())->withNativeType(Type::list($child))->withReadable(true)->withSchema(['type' => Schema::UNKNOWN_TYPE]) - ); - $propertyMetadataFactoryProphecy->create(NullableObjectCollection::class, 'optionalMixedChildren', Argument::cetera())->willReturn( - (new ApiProperty())->withNativeType(Type::nullable(Type::list(Type::union($child, Type::object(NotAResource::class)))))->withReadable(true)->withSchema(['type' => Schema::UNKNOWN_TYPE]) + $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 => [], + }) ); - $propertyMetadataFactoryProphecy->create(GenericChild::class, 'property', Argument::cetera())->willReturn( - (new ApiProperty())->withNativeType(Type::string())->withReadable(true)->withSchema(['type' => 'string']) + + $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]) ); - $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); - $resourceClassResolverProphecy->isResourceClass(Argument::any())->willReturn(false); + $resourceClassResolver = $this->createStub(ResourceClassResolverInterface::class); + $resourceClassResolver->method('isResourceClass')->willReturn(false); $schemaFactory = new SchemaFactory( - resourceMetadataFactory: $resourceMetadataFactoryProphecy->reveal(), - propertyNameCollectionFactory: $propertyNameCollectionFactoryProphecy->reveal(), - propertyMetadataFactory: $propertyMetadataFactoryProphecy->reveal(), - resourceClassResolver: $resourceClassResolverProphecy->reveal(), + resourceMetadataFactory: $this->createStub(ResourceMetadataCollectionFactoryInterface::class), + propertyNameCollectionFactory: $propertyNameCollectionFactory, + propertyMetadataFactory: $propertyMetadataFactory, + resourceClassResolver: $resourceClassResolver, definitionNameFactory: new DefinitionNameFactory(), ); $resultSchema = $schemaFactory->buildSchema(NullableObjectCollection::class);