From d1550c68a25636319215139613ea168e8ea5fee9 Mon Sep 17 00:00:00 2001 From: soyuka Date: Tue, 2 Jun 2026 17:55:17 +0200 Subject: [PATCH 1/2] fix(openapi): emit valid 3.0 schemas when downgrading from 3.1 LegacyOpenApiNormalizer used a shallow scan that converted type: ['x', 'null'] to anyOf at the top-level properties only. This produced invalid 3.0 output: orphan items keys, type: 'null' entries (not a valid 3.0 type), and no recursion into nested embeddables, items, allOf/oneOf/anyOf, or path-level inline schemas. Rewrite as a recursive walk that: - maps nullable types to type + nullable: true (3.0 idiomatic); - preserves items/properties/additionalProperties on nullable arrays and objects; - recurses into properties, patternProperties, items, additionalProperties, allOf, oneOf, anyOf, not, contains, propertyNames, if/then/else; - walks path operation requestBody, responses, and parameter schemas; - converts examples to example recursively. Refs #6194. --- .../Serializer/LegacyOpenApiNormalizer.php | 134 +++++-- .../LegacyOpenApiNormalizerTest.php | 356 ++++++++++++++++++ 2 files changed, 467 insertions(+), 23 deletions(-) create mode 100644 src/OpenApi/Tests/Serializer/LegacyOpenApiNormalizerTest.php diff --git a/src/OpenApi/Serializer/LegacyOpenApiNormalizer.php b/src/OpenApi/Serializer/LegacyOpenApiNormalizer.php index 7f47f737fa5..747b2d23752 100644 --- a/src/OpenApi/Serializer/LegacyOpenApiNormalizer.php +++ b/src/OpenApi/Serializer/LegacyOpenApiNormalizer.php @@ -18,6 +18,11 @@ final class LegacyOpenApiNormalizer implements NormalizerInterface { public const SPEC_VERSION = 'spec_version'; + + private const SCHEMA_BRANCH_KEYS = ['properties', 'patternProperties']; + private const SCHEMA_LIST_KEYS = ['allOf', 'oneOf', 'anyOf']; + private const SCHEMA_NESTED_KEYS = ['items', 'additionalProperties', 'not', 'contains', 'propertyNames', 'if', 'then', 'else']; + private array $defaultContext = [ self::SPEC_VERSION => '3.1.0', ]; @@ -27,9 +32,6 @@ public function __construct(private readonly NormalizerInterface $decorated, arr $this->defaultContext = array_merge($this->defaultContext, $defaultContext); } - /** - * {@inheritdoc} - */ public function normalize(mixed $data, ?string $format = null, array $context = []): array { $openapi = $this->decorated->normalize($data, $format, $context); @@ -38,40 +40,126 @@ public function normalize(mixed $data, ?string $format = null, array $context = return $openapi; } - $schemas = &$openapi['components']['schemas']; $openapi['openapi'] = '3.0.0'; - foreach ($openapi['components']['schemas'] as $name => $component) { - foreach ($component['properties'] ?? [] as $property => $value) { - if (\is_array($value['type'] ?? false)) { - foreach ($value['type'] as $type) { - $schemas[$name]['properties'][$property]['anyOf'][] = ['type' => $type]; - } - unset($schemas[$name]['properties'][$property]['type']); - } - if (\is_array($value['examples'] ?? false)) { - $schemas[$name]['properties'][$property]['example'] = $value['examples']; - unset($schemas[$name]['properties'][$property]['examples']); - } - } + foreach ($openapi['components']['schemas'] ?? [] as $name => $component) { + $openapi['components']['schemas'][$name] = $this->downgradeSchema($component); + } + + foreach ($openapi['paths'] ?? [] as $path => $operations) { + $openapi['paths'][$path] = $this->downgradePathItem($operations); } return $openapi; } - /** - * {@inheritdoc} - */ public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool { return $this->decorated->supportsNormalization($data, $format, $context); } - /** - * {@inheritdoc} - */ public function getSupportedTypes(?string $format): array { return $this->decorated->getSupportedTypes($format); } + + private function downgradePathItem(mixed $pathItem): mixed + { + if (!\is_array($pathItem)) { + return $pathItem; + } + + foreach ($pathItem as $method => $operation) { + if (!\is_array($operation)) { + continue; + } + + if (isset($operation['requestBody']['content']) && \is_array($operation['requestBody']['content'])) { + foreach ($operation['requestBody']['content'] as $mediaType => $media) { + if (isset($media['schema'])) { + $pathItem[$method]['requestBody']['content'][$mediaType]['schema'] = $this->downgradeSchema($media['schema']); + } + } + } + + foreach ($operation['responses'] ?? [] as $status => $response) { + if (!\is_array($response)) { + continue; + } + foreach ($response['content'] ?? [] as $mediaType => $media) { + if (isset($media['schema'])) { + $pathItem[$method]['responses'][$status]['content'][$mediaType]['schema'] = $this->downgradeSchema($media['schema']); + } + } + } + + foreach ($operation['parameters'] ?? [] as $index => $parameter) { + if (isset($parameter['schema'])) { + $pathItem[$method]['parameters'][$index]['schema'] = $this->downgradeSchema($parameter['schema']); + } + } + } + + return $pathItem; + } + + private function downgradeSchema(mixed $schema): mixed + { + if (!\is_array($schema)) { + return $schema; + } + + if (\is_array($schema['type'] ?? null)) { + $types = array_values($schema['type']); + $nullable = \in_array('null', $types, true); + $nonNull = array_values(array_filter($types, static fn ($t) => 'null' !== $t)); + + if (1 === \count($nonNull)) { + $schema['type'] = $nonNull[0]; + } elseif ([] === $nonNull) { + unset($schema['type']); + } else { + unset($schema['type']); + $schema['anyOf'] = array_map(static fn ($t) => ['type' => $t], $nonNull); + } + + if ($nullable) { + $schema['nullable'] = true; + } + } + + if (\array_key_exists('examples', $schema)) { + $schema['example'] = $schema['examples']; + unset($schema['examples']); + } + + foreach (self::SCHEMA_BRANCH_KEYS as $key) { + if (!isset($schema[$key]) || !\is_array($schema[$key])) { + continue; + } + foreach ($schema[$key] as $name => $child) { + $schema[$key][$name] = $this->downgradeSchema($child); + } + } + + foreach (self::SCHEMA_LIST_KEYS as $key) { + if (!isset($schema[$key]) || !\is_array($schema[$key])) { + continue; + } + foreach ($schema[$key] as $index => $child) { + $schema[$key][$index] = $this->downgradeSchema($child); + } + } + + foreach (self::SCHEMA_NESTED_KEYS as $key) { + if (!isset($schema[$key])) { + continue; + } + if (\is_array($schema[$key])) { + $schema[$key] = $this->downgradeSchema($schema[$key]); + } + } + + return $schema; + } } diff --git a/src/OpenApi/Tests/Serializer/LegacyOpenApiNormalizerTest.php b/src/OpenApi/Tests/Serializer/LegacyOpenApiNormalizerTest.php new file mode 100644 index 00000000000..26899b3b6dd --- /dev/null +++ b/src/OpenApi/Tests/Serializer/LegacyOpenApiNormalizerTest.php @@ -0,0 +1,356 @@ + + * + * 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\OpenApi\Tests\Serializer; + +use ApiPlatform\OpenApi\Serializer\LegacyOpenApiNormalizer; +use PHPUnit\Framework\TestCase; +use Prophecy\PhpUnit\ProphecyTrait; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + +final class LegacyOpenApiNormalizerTest extends TestCase +{ + use ProphecyTrait; + + public function testReturnsUntouchedWhenSpecVersionIsNot30(): void + { + $document = [ + 'openapi' => '3.1.0', + 'components' => ['schemas' => [ + 'Dummy' => [ + 'properties' => [ + 'name' => ['type' => ['string', 'null']], + ], + ], + ]], + ]; + + $normalizer = $this->buildNormalizer($document); + + $this->assertSame($document, $normalizer->normalize(new \stdClass(), null, [])); + } + + public function testConvertsNullableScalarUsingNullableFlag(): void + { + $document = [ + 'openapi' => '3.1.0', + 'components' => ['schemas' => [ + 'Dummy' => [ + 'properties' => [ + 'name' => ['type' => ['string', 'null']], + ], + ], + ]], + ]; + + $expected = [ + 'openapi' => '3.0.0', + 'components' => ['schemas' => [ + 'Dummy' => [ + 'properties' => [ + 'name' => ['type' => 'string', 'nullable' => true], + ], + ], + ]], + ]; + + $normalizer = $this->buildNormalizer($document); + + $this->assertSame($expected, $normalizer->normalize(new \stdClass(), null, ['spec_version' => '3.0.0'])); + } + + public function testKeepsItemsWhenArrayTypeIsNullable(): void + { + $document = [ + 'openapi' => '3.1.0', + 'components' => ['schemas' => [ + 'Dummy' => [ + 'properties' => [ + 'tags' => [ + 'type' => ['array', 'null'], + 'items' => ['type' => 'string'], + ], + ], + ], + ]], + ]; + + $expected = [ + 'openapi' => '3.0.0', + 'components' => ['schemas' => [ + 'Dummy' => [ + 'properties' => [ + 'tags' => [ + 'type' => 'array', + 'items' => ['type' => 'string'], + 'nullable' => true, + ], + ], + ], + ]], + ]; + + $normalizer = $this->buildNormalizer($document); + + $this->assertSame($expected, $normalizer->normalize(new \stdClass(), null, ['spec_version' => '3.0.0'])); + } + + public function testRecursesIntoNestedProperties(): void + { + $document = [ + 'openapi' => '3.1.0', + 'components' => ['schemas' => [ + 'TestResource' => [ + 'properties' => [ + 'testEmbeddable' => [ + 'type' => ['object', 'null'], + 'properties' => [ + 'testArrayOrNull' => [ + 'type' => ['array', 'null'], + 'items' => ['type' => 'string'], + ], + ], + ], + ], + ], + ]], + ]; + + $expected = [ + 'openapi' => '3.0.0', + 'components' => ['schemas' => [ + 'TestResource' => [ + 'properties' => [ + 'testEmbeddable' => [ + 'type' => 'object', + 'properties' => [ + 'testArrayOrNull' => [ + 'type' => 'array', + 'items' => ['type' => 'string'], + 'nullable' => true, + ], + ], + 'nullable' => true, + ], + ], + ], + ]], + ]; + + $normalizer = $this->buildNormalizer($document); + + $this->assertSame($expected, $normalizer->normalize(new \stdClass(), null, ['spec_version' => '3.0.0'])); + } + + public function testRecursesIntoItems(): void + { + $document = [ + 'openapi' => '3.1.0', + 'components' => ['schemas' => [ + 'Dummy' => [ + 'properties' => [ + 'tags' => [ + 'type' => 'array', + 'items' => ['type' => ['string', 'null']], + ], + ], + ], + ]], + ]; + + $expected = [ + 'openapi' => '3.0.0', + 'components' => ['schemas' => [ + 'Dummy' => [ + 'properties' => [ + 'tags' => [ + 'type' => 'array', + 'items' => ['type' => 'string', 'nullable' => true], + ], + ], + ], + ]], + ]; + + $normalizer = $this->buildNormalizer($document); + + $this->assertSame($expected, $normalizer->normalize(new \stdClass(), null, ['spec_version' => '3.0.0'])); + } + + public function testRecursesIntoAllOfOneOfAnyOf(): void + { + $document = [ + 'openapi' => '3.1.0', + 'components' => ['schemas' => [ + 'Dummy' => [ + 'properties' => [ + 'a' => ['allOf' => [['type' => ['string', 'null']]]], + 'b' => ['oneOf' => [['type' => ['integer', 'null']]]], + 'c' => ['anyOf' => [['type' => ['boolean', 'null']]]], + ], + ], + ]], + ]; + + $expected = [ + 'openapi' => '3.0.0', + 'components' => ['schemas' => [ + 'Dummy' => [ + 'properties' => [ + 'a' => ['allOf' => [['type' => 'string', 'nullable' => true]]], + 'b' => ['oneOf' => [['type' => 'integer', 'nullable' => true]]], + 'c' => ['anyOf' => [['type' => 'boolean', 'nullable' => true]]], + ], + ], + ]], + ]; + + $normalizer = $this->buildNormalizer($document); + + $this->assertSame($expected, $normalizer->normalize(new \stdClass(), null, ['spec_version' => '3.0.0'])); + } + + public function testRecursesIntoAdditionalProperties(): void + { + $document = [ + 'openapi' => '3.1.0', + 'components' => ['schemas' => [ + 'Dummy' => [ + 'type' => 'object', + 'additionalProperties' => ['type' => ['string', 'null']], + ], + ]], + ]; + + $expected = [ + 'openapi' => '3.0.0', + 'components' => ['schemas' => [ + 'Dummy' => [ + 'type' => 'object', + 'additionalProperties' => ['type' => 'string', 'nullable' => true], + ], + ]], + ]; + + $normalizer = $this->buildNormalizer($document); + + $this->assertSame($expected, $normalizer->normalize(new \stdClass(), null, ['spec_version' => '3.0.0'])); + } + + public function testFallsBackToAnyOfForMultipleNonNullTypes(): void + { + $document = [ + 'openapi' => '3.1.0', + 'components' => ['schemas' => [ + 'Dummy' => [ + 'properties' => [ + 'mixed' => ['type' => ['string', 'integer', 'null']], + ], + ], + ]], + ]; + + $normalizer = $this->buildNormalizer($document); + $result = $normalizer->normalize(new \stdClass(), null, ['spec_version' => '3.0.0']); + + $property = $result['components']['schemas']['Dummy']['properties']['mixed']; + $this->assertArrayNotHasKey('type', $property); + $this->assertSame([ + ['type' => 'string'], + ['type' => 'integer'], + ], $property['anyOf']); + $this->assertTrue($property['nullable']); + } + + public function testConvertsExamplesToExampleRecursively(): void + { + $document = [ + 'openapi' => '3.1.0', + 'components' => ['schemas' => [ + 'Dummy' => [ + 'properties' => [ + 'name' => ['type' => 'string', 'examples' => ['Alice', 'Bob']], + 'nested' => [ + 'type' => 'object', + 'properties' => [ + 'inner' => ['type' => 'string', 'examples' => ['x']], + ], + ], + ], + ], + ]], + ]; + + $expected = [ + 'openapi' => '3.0.0', + 'components' => ['schemas' => [ + 'Dummy' => [ + 'properties' => [ + 'name' => ['type' => 'string', 'example' => ['Alice', 'Bob']], + 'nested' => [ + 'type' => 'object', + 'properties' => [ + 'inner' => ['type' => 'string', 'example' => ['x']], + ], + ], + ], + ], + ]], + ]; + + $normalizer = $this->buildNormalizer($document); + + $this->assertSame($expected, $normalizer->normalize(new \stdClass(), null, ['spec_version' => '3.0.0'])); + } + + public function testWalksPathOperationSchemas(): void + { + $document = [ + 'openapi' => '3.1.0', + 'paths' => [ + '/dummies' => [ + 'post' => [ + 'requestBody' => [ + 'content' => [ + 'application/json' => [ + 'schema' => [ + 'type' => 'object', + 'properties' => [ + 'name' => ['type' => ['string', 'null']], + ], + ], + ], + ], + ], + ], + ], + ], + 'components' => ['schemas' => []], + ]; + + $normalizer = $this->buildNormalizer($document); + $result = $normalizer->normalize(new \stdClass(), null, ['spec_version' => '3.0.0']); + + $schema = $result['paths']['/dummies']['post']['requestBody']['content']['application/json']['schema']; + $this->assertSame('object', $schema['type']); + $this->assertSame(['type' => 'string', 'nullable' => true], $schema['properties']['name']); + } + + private function buildNormalizer(array $document): LegacyOpenApiNormalizer + { + $decorated = $this->prophesize(NormalizerInterface::class); + $decorated->normalize(\Prophecy\Argument::any(), \Prophecy\Argument::any(), \Prophecy\Argument::any())->willReturn($document); + + return new LegacyOpenApiNormalizer($decorated->reveal()); + } +} From c06bf149e210a27a791242a82aae8006e8bc4e2c Mon Sep 17 00:00:00 2001 From: soyuka Date: Tue, 2 Jun 2026 22:19:26 +0200 Subject: [PATCH 2/2] test(openapi): align 3.0 functional assertions with nullable form The 3.0 downgrade now emits the idiomatic type + nullable: true shape instead of anyOf with a type: null branch. --- tests/Functional/OpenApiTest.php | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/tests/Functional/OpenApiTest.php b/tests/Functional/OpenApiTest.php index 546861f1a70..6ad747110a5 100644 --- a/tests/Functional/OpenApiTest.php +++ b/tests/Functional/OpenApiTest.php @@ -590,14 +590,10 @@ public function testRetrieveTheOpenApiDocumentationWith30Specification(): void $json = $response->toArray(); $this->assertSame('3.0.0', $json['openapi']); - $this->assertEquals([ - ['type' => 'integer'], - ['type' => 'null'], - ], $json['components']['schemas']['DummyBoolean']['properties']['id']['anyOf']); - $this->assertEquals([ - ['type' => 'boolean'], - ['type' => 'null'], - ], $json['components']['schemas']['DummyBoolean']['properties']['isDummyBoolean']['anyOf']); + $this->assertSame('integer', $json['components']['schemas']['DummyBoolean']['properties']['id']['type']); + $this->assertTrue($json['components']['schemas']['DummyBoolean']['properties']['id']['nullable']); + $this->assertSame('boolean', $json['components']['schemas']['DummyBoolean']['properties']['isDummyBoolean']['type']); + $this->assertTrue($json['components']['schemas']['DummyBoolean']['properties']['isDummyBoolean']['nullable']); $this->assertArrayNotHasKey('owl:maxCardinality', $json['components']['schemas']['DummyBoolean']['properties']['isDummyBoolean']); }