Skip to content

Corrected schema generation for array<string, T>, object, ?array, ?object and ?type #3402

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

46 changes: 42 additions & 4 deletions src/JsonSchema/TypeFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'];
Expand All @@ -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'];
Expand Down Expand Up @@ -125,4 +138,29 @@ private function getClassType(?string $className, string $format = 'json', ?bool

return ['$ref' => $subSchema['$ref']];
}

/**
* @param array<string, mixed> $jsonSchema
*
* @return array<string, mixed>
*/
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]);
}
}
236 changes: 234 additions & 2 deletions tests/JsonSchema/TypeFactoryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()));
}
}
29 changes: 16 additions & 13 deletions tests/Swagger/Serializer/DocumentationNormalizerV2Test.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
]),
],
]),
]),
];
Expand Down Expand Up @@ -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()));

Expand All @@ -402,10 +409,6 @@ interface_exists(AdvancedNameConverterInterface::class)
* @var PropertyMetadataFactoryInterface
*/
$propertyMetadataFactory = $propertyMetadataFactoryProphecy->reveal();
/**
* @var NameConverterInterface
*/
$nameConverter = $nameConverterProphecy->reveal();

/**
* @var TypeFactoryInterface|null
Expand Down
Loading