From d1dc8234194f16387130432bc93a065b228668d6 Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 22 Oct 2026 09:23:18 +0200 Subject: [PATCH] fix(openapi): use nullable instead of null type in 3.0 Fixes #8751 --- .../Serializer/LegacyOpenApiNormalizer.php | 19 +++++ .../LegacyOpenApiNormalizerTest.php | 31 +++++++ .../JsonApi/NullableRelationDummy.php | 38 +++++++++ .../OpenApiLegacySpecVersionTest.php | 80 +++++++++++++++++++ 4 files changed, 168 insertions(+) create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonApi/NullableRelationDummy.php create mode 100644 tests/Functional/OpenApiLegacySpecVersionTest.php diff --git a/src/OpenApi/Serializer/LegacyOpenApiNormalizer.php b/src/OpenApi/Serializer/LegacyOpenApiNormalizer.php index 435f11a441..38c212a313 100644 --- a/src/OpenApi/Serializer/LegacyOpenApiNormalizer.php +++ b/src/OpenApi/Serializer/LegacyOpenApiNormalizer.php @@ -129,6 +129,25 @@ private function downgradeSchema(mixed $schema): mixed } } + foreach (['oneOf', 'anyOf'] as $key) { + if (!\is_array($schema[$key] ?? null)) { + continue; + } + + $members = array_values(array_filter($schema[$key], static fn ($member) => ['type' => 'null'] !== $member)); + if (\count($members) === \count($schema[$key])) { + continue; + } + + unset($schema[$key]); + if (1 === \count($members) && !isset($members[0]['$ref'])) { + $schema += $members[0]; + } elseif ([] !== $members) { + $schema[$key] = $members; + } + $schema['nullable'] = true; + } + if (\array_key_exists('examples', $schema)) { $schema['example'] = $schema['examples']; unset($schema['examples']); diff --git a/src/OpenApi/Tests/Serializer/LegacyOpenApiNormalizerTest.php b/src/OpenApi/Tests/Serializer/LegacyOpenApiNormalizerTest.php index 4bf988a185..db66710afa 100644 --- a/src/OpenApi/Tests/Serializer/LegacyOpenApiNormalizerTest.php +++ b/src/OpenApi/Tests/Serializer/LegacyOpenApiNormalizerTest.php @@ -152,6 +152,37 @@ public function testRecursesIntoNestedProperties(): void $this->assertSame($expected, $normalizer->normalize(new \stdClass(), null, ['spec_version' => '3.0.0'])); } + public function testConvertsNullTypeMemberOfOneOfToNullable(): void + { + $document = [ + 'openapi' => '3.1.0', + 'components' => ['schemas' => [ + 'Dummy' => [ + 'properties' => [ + 'inline' => ['oneOf' => [['type' => 'null'], ['type' => 'object', 'properties' => ['id' => ['type' => 'string']]]]], + 'reference' => ['oneOf' => [['type' => 'null'], ['$ref' => '#/components/schemas/Other']]], + 'several' => ['anyOf' => [['type' => 'string'], ['type' => 'null'], ['type' => 'integer']]], + ], + ], + ]], + ]; + + $expected = [ + 'openapi' => '3.0.0', + 'components' => ['schemas' => [ + 'Dummy' => [ + 'properties' => [ + 'inline' => ['type' => 'object', 'properties' => ['id' => ['type' => 'string']], 'nullable' => true], + 'reference' => ['oneOf' => [['$ref' => '#/components/schemas/Other']], 'nullable' => true], + 'several' => ['anyOf' => [['type' => 'string'], ['type' => 'integer']], 'nullable' => true], + ], + ], + ]], + ]; + + $this->assertSame($expected, $this->buildNormalizer($document)->normalize(new \stdClass(), null, ['spec_version' => '3.0.0'])); + } + public function testRecursesIntoItems(): void { $document = [ diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonApi/NullableRelationDummy.php b/tests/Fixtures/TestBundle/ApiResource/JsonApi/NullableRelationDummy.php new file mode 100644 index 0000000000..bd45b4edbb --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonApi/NullableRelationDummy.php @@ -0,0 +1,38 @@ + + * + * 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\Tests\Fixtures\TestBundle\ApiResource\JsonApi; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; + +#[ApiResource( + shortName: 'JsonApiNullableRelation', + formats: ['jsonapi' => ['application/vnd.api+json']], + operations: [ + new Get(uriTemplate: '/jsonapi_nullable_relations/{id}', provider: [self::class, 'provide']), + ], +)] +class NullableRelationDummy +{ + #[ApiProperty(identifier: true)] + public int $id = 1; + + public ?NullableRelationDummy $parent = null; + + public static function provide(): self + { + return new self(); + } +} diff --git a/tests/Functional/OpenApiLegacySpecVersionTest.php b/tests/Functional/OpenApiLegacySpecVersionTest.php new file mode 100644 index 0000000000..f050bc9f68 --- /dev/null +++ b/tests/Functional/OpenApiLegacySpecVersionTest.php @@ -0,0 +1,80 @@ + + * + * 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\Tests\Functional; + +use ApiPlatform\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonApi\NullableRelationDummy; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class OpenApiLegacySpecVersionTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [NullableRelationDummy::class]; + } + + public function testOpenApi30DocumentOnlyHasOpenApi30RootFields(): void + { + $response = self::createClient()->request('GET', '/docs?spec_version=3.0.0', [ + 'headers' => ['Accept' => 'application/vnd.openapi+json'], + ]); + + $this->assertResponseIsSuccessful(); + $document = $response->toArray(); + $this->assertSame('3.0.0', $document['openapi']); + + $allowed = ['openapi', 'info', 'servers', 'paths', 'components', 'security', 'tags', 'externalDocs']; + $extra = array_filter(array_diff(array_keys($document), $allowed), static fn (string $key): bool => !str_starts_with($key, 'x-')); + $this->assertSame([], array_values($extra)); + } + + public function testOpenApi30DocumentDoesNotUseTheNullType(): void + { + $response = self::createClient()->request('GET', '/docs?spec_version=3.0.0', [ + 'headers' => ['Accept' => 'application/vnd.openapi+json'], + ]); + + $this->assertResponseIsSuccessful(); + $document = $response->toArray(); + $this->assertSame([], self::findNullTypes($document['components']['schemas'] ?? [])); + + $relationship = $document['components']['schemas']['JsonApiNullableRelation.jsonapi.resource']['properties']['relationships']['properties']['parent']['properties']['data'] ?? null; + $this->assertTrue($relationship['nullable'] ?? false); + } + + /** + * @return list + */ + private static function findNullTypes(mixed $node, string $path = ''): array + { + if (!\is_array($node)) { + return []; + } + + $found = []; + if (isset($node['type']) && ((\is_array($node['type']) && array_is_list($node['type'])) || 'null' === $node['type'])) { + $found[] = $path; + } + + foreach ($node as $key => $child) { + $found = [...$found, ...self::findNullTypes($child, $path.'/'.$key)]; + } + + return $found; + } +}