diff --git a/src/JsonSchema/TypeFactory.php b/src/JsonSchema/TypeFactory.php index b6825ceaf70..f3d3f48a63e 100644 --- a/src/JsonSchema/TypeFactory.php +++ b/src/JsonSchema/TypeFactory.php @@ -50,14 +50,27 @@ public function setSchemaFactory(SchemaFactoryInterface $schemaFactory): void public function getType(Type $type, string $format = 'json', ?bool $readableLink = null, ?array $serializerContext = null, Schema $schema = null): array { if ($type->isCollection()) { - $subType = new Type($type->getBuiltinType(), $type->isNullable(), $type->getClassName(), false); + $keyType = $type->getCollectionKeyType(); + $subType = $type->getCollectionValueType() ?? new Type($type->getBuiltinType(), false, $type->getClassName(), false); - return [ + if (null !== $keyType && Type::BUILTIN_TYPE_STRING === $keyType->getBuiltinType()) { + return $this->addNullabilityToTypeDefinition([ + 'type' => 'object', + 'additionalProperties' => $this->getType($subType, $format, $readableLink, $serializerContext, $schema), + ], $type, $schema); + } + + return $this->addNullabilityToTypeDefinition([ 'type' => 'array', 'items' => $this->getType($subType, $format, $readableLink, $serializerContext, $schema), - ]; + ], $type, $schema); } + return $this->addNullabilityToTypeDefinition($this->makeBasicType($type, $format, $readableLink, $serializerContext, $schema), $type, $schema); + } + + private function makeBasicType(Type $type, string $format = 'json', ?bool $readableLink = null, ?array $serializerContext = null, Schema $schema = null): array + { switch ($type->getBuiltinType()) { case Type::BUILTIN_TYPE_INT: return ['type' => 'integer']; @@ -75,7 +88,7 @@ public function getType(Type $type, string $format = 'json', ?bool $readableLink /** * Gets the JSON Schema document which specifies the data type corresponding to the given PHP class, and recursively adds needed new schema to the current schema if provided. */ - private function getClassType(?string $className, string $format = 'json', ?bool $readableLink = null, ?array $serializerContext = null, ?Schema $schema = null): array + private function getClassType(?string $className, string $format, ?bool $readableLink, ?array $serializerContext, ?Schema $schema): array { if (null === $className) { return ['type' => 'string']; @@ -125,4 +138,29 @@ private function getClassType(?string $className, string $format = 'json', ?bool return ['$ref' => $subSchema['$ref']]; } + + /** + * @param array $jsonSchema + * + * @return array + */ + private function addNullabilityToTypeDefinition(array $jsonSchema, Type $type, ?Schema $schema): array + { + if ($schema && Schema::VERSION_SWAGGER === $schema->getVersion()) { + return $jsonSchema; + } + + if (!$type->isNullable()) { + return $jsonSchema; + } + + if (\array_key_exists('$ref', $jsonSchema)) { + return [ + 'nullable' => true, + 'anyOf' => [$jsonSchema], + ]; + } + + return array_merge($jsonSchema, ['nullable' => true]); + } } diff --git a/tests/JsonSchema/TypeFactoryTest.php b/tests/JsonSchema/TypeFactoryTest.php index 000b05aea36..7f6259501ed 100644 --- a/tests/JsonSchema/TypeFactoryTest.php +++ b/tests/JsonSchema/TypeFactoryTest.php @@ -29,19 +29,225 @@ class TypeFactoryTest extends TestCase public function testGetType(array $schema, Type $type): void { $typeFactory = new TypeFactory(); - $this->assertSame($schema, $typeFactory->getType($type)); + $this->assertEquals($schema, $typeFactory->getType($type, 'json', null, null, new Schema())); } public function typeProvider(): iterable { yield [['type' => 'integer'], new Type(Type::BUILTIN_TYPE_INT)]; + yield [['nullable' => true, 'type' => 'integer'], new Type(Type::BUILTIN_TYPE_INT, true)]; yield [['type' => 'number'], new Type(Type::BUILTIN_TYPE_FLOAT)]; + yield [['nullable' => true, 'type' => 'number'], new Type(Type::BUILTIN_TYPE_FLOAT, true)]; yield [['type' => 'boolean'], new Type(Type::BUILTIN_TYPE_BOOL)]; + yield [['nullable' => true, 'type' => 'boolean'], new Type(Type::BUILTIN_TYPE_BOOL, true)]; + yield [['type' => 'string'], new Type(Type::BUILTIN_TYPE_STRING)]; + yield [['nullable' => true, 'type' => 'string'], new Type(Type::BUILTIN_TYPE_STRING, true)]; yield [['type' => 'string'], new Type(Type::BUILTIN_TYPE_OBJECT)]; + yield [['nullable' => true, 'type' => 'string'], new Type(Type::BUILTIN_TYPE_OBJECT, true)]; yield [['type' => 'string', 'format' => 'date-time'], new Type(Type::BUILTIN_TYPE_OBJECT, false, \DateTimeImmutable::class)]; + yield [['nullable' => true, 'type' => 'string', 'format' => 'date-time'], new Type(Type::BUILTIN_TYPE_OBJECT, true, \DateTimeImmutable::class)]; yield [['type' => 'string', 'format' => 'duration'], new Type(Type::BUILTIN_TYPE_OBJECT, false, \DateInterval::class)]; - yield [['type' => 'string'], new Type(Type::BUILTIN_TYPE_OBJECT, false, Dummy::class)]; + yield [['type' => 'string', 'format' => 'iri-reference'], new Type(Type::BUILTIN_TYPE_OBJECT, false, Dummy::class)]; + yield [['nullable' => true, 'type' => 'string', 'format' => 'iri-reference'], new Type(Type::BUILTIN_TYPE_OBJECT, true, Dummy::class)]; yield [['type' => 'array', 'items' => ['type' => 'string']], new Type(Type::BUILTIN_TYPE_STRING, false, null, true)]; + yield 'array can be itself nullable' => [ + ['nullable' => true, 'type' => 'array', 'items' => ['type' => 'string']], + new Type(Type::BUILTIN_TYPE_STRING, true, null, true), + ]; + + yield 'array can contain nullable values' => [ + [ + 'type' => 'array', + 'items' => [ + 'nullable' => true, + 'type' => 'string', + ], + ], + new Type(Type::BUILTIN_TYPE_STRING, false, null, true, null, new Type(Type::BUILTIN_TYPE_STRING, true, null, false)), + ]; + + yield 'map with string keys becomes an object' => [ + ['type' => 'object', 'additionalProperties' => ['type' => 'string']], + new Type( + Type::BUILTIN_TYPE_STRING, + false, + null, + true, + new Type(Type::BUILTIN_TYPE_STRING, false, null, false) + ), + ]; + + yield 'nullable map with string keys becomes a nullable object' => [ + [ + 'nullable' => true, + 'type' => 'object', + 'additionalProperties' => ['type' => 'string'], + ], + new Type( + Type::BUILTIN_TYPE_STRING, + true, + null, + true, + new Type(Type::BUILTIN_TYPE_STRING, false, null, false), + new Type(Type::BUILTIN_TYPE_STRING, false, null, false) + ), + ]; + + yield 'map value type will be considered' => [ + ['type' => 'object', 'additionalProperties' => ['type' => 'integer']], + new Type( + Type::BUILTIN_TYPE_ARRAY, + false, + null, + true, + new Type(Type::BUILTIN_TYPE_STRING, false, null, false), + new Type(Type::BUILTIN_TYPE_INT, false, null, false) + ), + ]; + + yield 'map value type nullability will be considered' => [ + [ + 'type' => 'object', + 'additionalProperties' => [ + 'nullable' => true, + 'type' => 'integer', + ], + ], + new Type( + Type::BUILTIN_TYPE_ARRAY, + false, + null, + true, + new Type(Type::BUILTIN_TYPE_STRING, false, null, false), + new Type(Type::BUILTIN_TYPE_INT, true, null, false) + ), + ]; + + yield 'nullable map can contain nullable values' => [ + [ + 'nullable' => true, + 'type' => 'object', + 'additionalProperties' => [ + 'nullable' => true, + 'type' => 'integer', + ], + ], + new Type( + Type::BUILTIN_TYPE_ARRAY, + true, + null, + true, + new Type(Type::BUILTIN_TYPE_STRING, false, null, false), + new Type(Type::BUILTIN_TYPE_INT, true, null, false) + ), + ]; + } + + /** @dataProvider openAPIV2typeProvider */ + public function testGetTypeWithOpenAPIV2Syntax(array $schema, Type $type): void + { + $typeFactory = new TypeFactory(); + $this->assertSame($schema, $typeFactory->getType($type, 'json', null, null, new Schema(Schema::VERSION_SWAGGER))); + } + + public function openAPIV2typeProvider(): iterable + { + yield [['type' => 'integer'], new Type(Type::BUILTIN_TYPE_INT)]; + yield [['type' => 'integer'], new Type(Type::BUILTIN_TYPE_INT, true)]; + yield [['type' => 'number'], new Type(Type::BUILTIN_TYPE_FLOAT)]; + yield [['type' => 'number'], new Type(Type::BUILTIN_TYPE_FLOAT, true)]; + yield [['type' => 'boolean'], new Type(Type::BUILTIN_TYPE_BOOL)]; + yield [['type' => 'boolean'], new Type(Type::BUILTIN_TYPE_BOOL, true)]; + yield [['type' => 'string'], new Type(Type::BUILTIN_TYPE_STRING)]; + yield [['type' => 'string'], new Type(Type::BUILTIN_TYPE_STRING, true)]; + yield [['type' => 'string'], new Type(Type::BUILTIN_TYPE_OBJECT)]; + yield [['type' => 'string'], new Type(Type::BUILTIN_TYPE_OBJECT, true)]; + yield [['type' => 'string', 'format' => 'date-time'], new Type(Type::BUILTIN_TYPE_OBJECT, false, \DateTimeImmutable::class)]; + yield [['type' => 'string', 'format' => 'date-time'], new Type(Type::BUILTIN_TYPE_OBJECT, true, \DateTimeImmutable::class)]; + yield [['type' => 'string', 'format' => 'duration'], new Type(Type::BUILTIN_TYPE_OBJECT, false, \DateInterval::class)]; + yield [['type' => 'string', 'format' => 'iri-reference'], new Type(Type::BUILTIN_TYPE_OBJECT, false, Dummy::class)]; + yield [['type' => 'string', 'format' => 'iri-reference'], new Type(Type::BUILTIN_TYPE_OBJECT, true, Dummy::class)]; + yield [['type' => 'array', 'items' => ['type' => 'string']], new Type(Type::BUILTIN_TYPE_STRING, false, null, true)]; + yield 'array can be itself nullable, but ignored in OpenAPI V2' => [ + ['type' => 'array', 'items' => ['type' => 'string']], + new Type(Type::BUILTIN_TYPE_STRING, true, null, true), + ]; + + yield 'array can contain nullable values, but ignored in OpenAPI V2' => [ + [ + 'type' => 'array', + 'items' => ['type' => 'string'], + ], + new Type(Type::BUILTIN_TYPE_STRING, false, null, true, null, new Type(Type::BUILTIN_TYPE_STRING, true, null, false)), + ]; + + yield 'map with string keys becomes an object' => [ + ['type' => 'object', 'additionalProperties' => ['type' => 'string']], + new Type( + Type::BUILTIN_TYPE_STRING, + false, + null, + true, + new Type(Type::BUILTIN_TYPE_STRING, false, null, false) + ), + ]; + + yield 'nullable map with string keys becomes a nullable object, but ignored in OpenAPI V2' => [ + [ + 'type' => 'object', + 'additionalProperties' => ['type' => 'string'], + ], + new Type( + Type::BUILTIN_TYPE_STRING, + true, + null, + true, + new Type(Type::BUILTIN_TYPE_STRING, false, null, false), + new Type(Type::BUILTIN_TYPE_STRING, false, null, false) + ), + ]; + + yield 'map value type will be considered' => [ + ['type' => 'object', 'additionalProperties' => ['type' => 'integer']], + new Type( + Type::BUILTIN_TYPE_ARRAY, + false, + null, + true, + new Type(Type::BUILTIN_TYPE_STRING, false, null, false), + new Type(Type::BUILTIN_TYPE_INT, false, null, false) + ), + ]; + + yield 'map value type nullability will be considered, but ignored in OpenAPI V2' => [ + [ + 'type' => 'object', + 'additionalProperties' => ['type' => 'integer'], + ], + new Type( + Type::BUILTIN_TYPE_ARRAY, + false, + null, + true, + new Type(Type::BUILTIN_TYPE_STRING, false, null, false), + new Type(Type::BUILTIN_TYPE_INT, true, null, false) + ), + ]; + + yield 'nullable map can contain nullable values, but ignored in OpenAPI V2' => [ + [ + 'type' => 'object', + 'additionalProperties' => ['type' => 'integer'], + ], + new Type( + Type::BUILTIN_TYPE_ARRAY, + true, + null, + true, + new Type(Type::BUILTIN_TYPE_STRING, false, null, false), + new Type(Type::BUILTIN_TYPE_INT, true, null, false) + ), + ]; } public function testGetClassType(): void @@ -59,4 +265,30 @@ public function testGetClassType(): void $this->assertSame(['$ref' => 'ref'], $typeFactory->getType(new Type(Type::BUILTIN_TYPE_OBJECT, false, Dummy::class), 'jsonld', true, ['foo' => 'bar'], new Schema())); } + + public function testGetClassTypeWithNullability(): void + { + $schemaFactory = $this->createMock(SchemaFactoryInterface::class); + + $schemaFactory + ->method('buildSchema') + ->willReturnCallback(static function (): Schema { + $schema = new Schema(); + + $schema['$ref'] = 'the-ref-name'; + $schema['description'] = 'more stuff here'; + + return $schema; + }); + + $typeFactory = new TypeFactory(); + $typeFactory->setSchemaFactory($schemaFactory); + + self::assertSame([ + 'nullable' => true, + 'anyOf' => [ + ['$ref' => 'the-ref-name'], + ], + ], $typeFactory->getType(new Type(Type::BUILTIN_TYPE_OBJECT, true, Dummy::class), 'jsonld', true, ['foo' => 'bar'], new Schema())); + } } diff --git a/tests/Swagger/Serializer/DocumentationNormalizerV2Test.php b/tests/Swagger/Serializer/DocumentationNormalizerV2Test.php index 684da2d83a4..7956cdea209 100644 --- a/tests/Swagger/Serializer/DocumentationNormalizerV2Test.php +++ b/tests/Swagger/Serializer/DocumentationNormalizerV2Test.php @@ -344,9 +344,10 @@ private function doTestNormalize(OperationMethodResolverInterface $operationMeth ]), 'dummyDate' => new \ArrayObject([ 'type' => 'string', - 'description' => 'This is a \DateTimeInterface object.', 'format' => 'date-time', - ]), ], + 'description' => 'This is a \DateTimeInterface object.', + ]), + ], ]), ]), ]; @@ -380,13 +381,19 @@ private function doTestNormalizeWithNameConverter(bool $legacy = false): void $propertyMetadataFactoryProphecy->create(Dummy::class, 'name')->shouldBeCalled()->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING), 'This is a name.', true, true, null, null, false)); $propertyMetadataFactoryProphecy->create(Dummy::class, 'nameConverted')->shouldBeCalled()->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING), 'This is a converted name.', true, true, null, null, false)); - $nameConverterProphecy = $this->prophesize( - interface_exists(AdvancedNameConverterInterface::class) - ? AdvancedNameConverterInterface::class - : NameConverterInterface::class - ); - $nameConverterProphecy->normalize('name', Dummy::class, DocumentationNormalizer::FORMAT, [])->willReturn('name')->shouldBeCalled(); - $nameConverterProphecy->normalize('nameConverted', Dummy::class, DocumentationNormalizer::FORMAT, [])->willReturn('name_converted')->shouldBeCalled(); + if (interface_exists(AdvancedNameConverterInterface::class)) { + $nameConverter = $this->createMock(AdvancedNameConverterInterface::class); + } else { + $nameConverter = $this->createMock(NameConverterInterface::class); + } + + $nameConverter->method('normalize') + ->with(self::logicalOr('name', 'nameConverted')) + ->willReturnCallback(static function (string $nameToNormalize): string { + return 'nameConverted' === $nameToNormalize + ? 'name_converted' + : $nameToNormalize; + }); $operationPathResolver = new CustomOperationPathResolver(new OperationPathResolver(new UnderscorePathSegmentNameGenerator())); @@ -402,10 +409,6 @@ interface_exists(AdvancedNameConverterInterface::class) * @var PropertyMetadataFactoryInterface */ $propertyMetadataFactory = $propertyMetadataFactoryProphecy->reveal(); - /** - * @var NameConverterInterface - */ - $nameConverter = $nameConverterProphecy->reveal(); /** * @var TypeFactoryInterface|null diff --git a/tests/Swagger/Serializer/DocumentationNormalizerV3Test.php b/tests/Swagger/Serializer/DocumentationNormalizerV3Test.php index 125add993c0..96406c4174d 100644 --- a/tests/Swagger/Serializer/DocumentationNormalizerV3Test.php +++ b/tests/Swagger/Serializer/DocumentationNormalizerV3Test.php @@ -385,9 +385,10 @@ private function doTestNormalize(OperationMethodResolverInterface $operationMeth 'description' => 'This is an initializable but not writable property.', ]), 'dummyDate' => new \ArrayObject([ + 'nullable' => true, 'type' => 'string', - 'description' => 'This is a \DateTimeInterface object.', 'format' => 'date-time', + 'description' => 'This is a \DateTimeInterface object.', ]), ], ]), @@ -2000,7 +2001,10 @@ public function testNormalizeWithNestedNormalizationGroups(): void ]), 'relatedDummy' => new \ArrayObject([ 'description' => 'This is a related dummy \o/.', - '$ref' => '#/components/schemas/'.$relatedDummyRef, + 'nullable' => true, + 'anyOf' => [ + ['$ref' => '#/components/schemas/'.$relatedDummyRef], + ], ]), ], ]),