Skip to content
Merged
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