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()); + } +} 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']); }