Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions src/TypeDescriber/ArrayDescriber.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
use OpenApi\Annotations\Schema;
use Symfony\Component\TypeInfo\Type;
use Symfony\Component\TypeInfo\Type\CollectionType;
use Symfony\Component\TypeInfo\TypeIdentifier;

/**
* @implements TypeDescriberInterface<CollectionType>
Expand All @@ -30,6 +31,31 @@ public function describe(Type $type, Schema $schema, array $context = []): void
throw new \LogicException('This describer only supports '.CollectionType::class.' with '.Type\UnionType::class.' as key type.');
}

// When the key type is arrayKey() (int|string union), the collection has
// no explicit key type (e.g. `array<T>` in PHPDoc). Treat as a JSON array
// rather than splitting into anyOf: [array, object].
$keyTypes = $type->getCollectionKeyType()->getTypes();
if (2 === \count($keyTypes)
&& $keyTypes[0] instanceof Type\BuiltinType
&& $keyTypes[1] instanceof Type\BuiltinType
&& self::isArrayKeyUnion($keyTypes[0]->getTypeIdentifier(), $keyTypes[1]->getTypeIdentifier())
) {
// When a Traversable object is used as a generic parameter
// (e.g. `list<MyTraversableClass>`), StringTypeResolver wraps it in
// CollectionType(ObjectType) with no key/value type info. Unwrap it
// so ClassDescriber can create a proper $ref.
$wrappedType = $type->getWrappedType();
if ($wrappedType instanceof Type\ObjectType) {
$this->describer->describe($wrappedType, $schema, $context);

return;
}

$this->describer->describe(Type::list($type->getCollectionValueType()), $schema, $context);

return;
Comment thread
gnutix marked this conversation as resolved.
}

$arrayTypes = array_map(
static fn (Type $keyType): Type => Type::array($type->getCollectionValueType(), $keyType),
$type->getCollectionKeyType()->getTypes()
Expand All @@ -47,4 +73,14 @@ public function supports(Type $type, array $context = []): bool
return $type instanceof CollectionType
&& $type->getCollectionKeyType() instanceof Type\UnionType;
}

/**
* Checks whether two type identifiers form an arrayKey() union (int|string),
* regardless of which order TypeInfo emits them.
*/
private static function isArrayKeyUnion(TypeIdentifier $a, TypeIdentifier $b): bool
{
return (TypeIdentifier::INT === $a && TypeIdentifier::STRING === $b)
|| (TypeIdentifier::STRING === $a && TypeIdentifier::INT === $b);
}
}
12 changes: 4 additions & 8 deletions tests/Functional/Controller/JMSController.php
Original file line number Diff line number Diff line change
Expand Up @@ -132,8 +132,7 @@ public function minUserNestedAction(): void
response: 200,
description: 'Success',
content: new Model(type: Article81::class)
)
]
)]
public function enum(): void
{
}
Expand All @@ -143,8 +142,7 @@ public function enum(): void
response: 200,
description: 'Success',
content: new Model(type: JMSAbstractUser::class)
)
]
)]
public function discriminatorMapAction(): void
{
}
Expand All @@ -154,8 +152,7 @@ public function discriminatorMapAction(): void
response: 200,
description: 'Success',
content: new Model(type: JMSEnum::class)
)
]
)]
public function enumArrayAction(): void
{
}
Expand All @@ -165,8 +162,7 @@ public function enumArrayAction(): void
response: 200,
description: 'Success',
content: new Model(type: JMSIgnoredProperty::class)
)
]
)]
public function ignoredProperty(): void
{
}
Expand Down
54 changes: 12 additions & 42 deletions tests/Functional/Fixtures/GenericTypesController.json
Original file line number Diff line number Diff line change
Expand Up @@ -126,20 +126,10 @@
}
},
"array": {
"oneOf": [
{
"type": "array",
"items": {
"type": "string"
}
},
{
"type": "object",
"additionalProperties": {
"type": "string"
}
}
]
"type": "array",
"items": {
"type": "string"
}
},
"list": {
"type": "array",
Expand All @@ -164,20 +154,10 @@
}
},
"array": {
"oneOf": [
{
"type": "array",
"items": {
"type": "integer"
}
},
{
"type": "object",
"additionalProperties": {
"type": "integer"
}
}
]
"type": "array",
"items": {
"type": "integer"
}
},
"list": {
"type": "array",
Expand All @@ -202,20 +182,10 @@
}
},
"array": {
"oneOf": [
{
"type": "array",
"items": {
"$ref": "#/components/schemas/RegularClass"
}
},
{
"type": "object",
"additionalProperties": {
"$ref": "#/components/schemas/RegularClass"
}
}
]
"type": "array",
"items": {
"$ref": "#/components/schemas/RegularClass"
}
},
"list": {
"type": "array",
Expand Down
16 changes: 3 additions & 13 deletions tests/Functional/FunctionalTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -1164,19 +1164,9 @@ public function testArbitraryArrayModel(): void
'type' => 'array',
'items' => [],
] : [
'oneOf' => [
[
'type' => 'array',
'items' => [
'nullable' => true,
],
],
[
'type' => 'object',
'additionalProperties' => [
'nullable' => true,
],
],
'type' => 'array',
'items' => [
'nullable' => true,
],
],
'moreThings' => [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,10 @@
],
"properties": {
"mixedArray": {
"oneOf": [
{
"type": "array",
"items": {
"type": "integer"
}
},
{
"type": "object",
"additionalProperties": {
"type": "integer"
}
}
]
"type": "array",
"items": {
"type": "integer"
}
}
},
"type": "object"
Expand Down
36 changes: 8 additions & 28 deletions tests/Functional/ModelDescriber/Fixtures/TypeInfo/ArrayOfInt.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,36 +6,16 @@
],
"properties": {
"untypedArray": {
"oneOf": [
{
"type": "array",
"items": {
"nullable": true
}
},
{
"type": "object",
"additionalProperties": {
"nullable": true
}
}
]
"type": "array",
"items": {
"nullable": true
}
},
"arrayOfIntegers": {
"oneOf": [
{
"type": "array",
"items": {
"type": "integer"
}
},
{
"type": "object",
"additionalProperties": {
"type": "integer"
}
}
]
"type": "array",
"items": {
"type": "integer"
}
},
"listOfIntegers": {
"type": "array",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,36 +6,16 @@
],
"properties": {
"untypedArray": {
"oneOf": [
{
"type": "array",
"items": {
"nullable": true
}
},
{
"type": "object",
"additionalProperties": {
"nullable": true
}
}
]
"type": "array",
"items": {
"nullable": true
}
},
"arrayOfStrings": {
"oneOf": [
{
"type": "array",
"items": {
"type": "string"
}
},
{
"type": "object",
"additionalProperties": {
"type": "string"
}
}
]
"type": "array",
"items": {
"type": "string"
}
},
"listOfStrings": {
"type": "array",
Expand Down
43 changes: 43 additions & 0 deletions tests/TypeDescriber/ArrayDescriberTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
namespace Nelmio\ApiDocBundle\Tests\TypeDescriber;

use Nelmio\ApiDocBundle\TypeDescriber\ArrayDescriber;
use Nelmio\ApiDocBundle\TypeDescriber\TypeDescriberInterface;
use OpenApi\Annotations\Schema;
use PHPUnit\Framework\TestCase;
use Symfony\Component\TypeInfo\Type;
Expand Down Expand Up @@ -47,4 +48,46 @@ public static function provideInvalidCollectionTypes(): \Generator
yield [Type::list()];
yield [Type::dict()];
}

/**
* When the key type is arrayKey() (int|string union), the describer should
* treat the collection as a list rather than splitting into anyOf [array, object].
*/
public function testArrayKeyUnionIsTreatedAsList(): void
{
$innerDescriber = $this->createMock(TypeDescriberInterface::class);
$innerDescriber->expects(self::once())
->method('describe')
->with(self::callback(static function (Type $type): bool {
// Should delegate a list(string) — i.e. CollectionType with int key and string value
return $type instanceof CollectionType
&& $type->isList()
&& 'string' === (string) $type->getCollectionValueType();
}));
Comment thread
gnutix marked this conversation as resolved.

$this->describer->setDescriber($innerDescriber);

// array<string> is resolved by TypeInfo as CollectionType with arrayKey() union key
$type = Type::array(Type::string());
$this->describer->describe($type, new Schema([]));
}

/**
* When a Traversable object is auto-wrapped in CollectionType(ObjectType),
* the describer should unwrap it and delegate the ObjectType directly.
*/
public function testTraversableObjectIsUnwrapped(): void
{
$objectType = Type::object(\ArrayObject::class);
// Simulate what StringTypeResolver does: wrap in CollectionType(ObjectType)
$collectionType = new CollectionType($objectType);

$innerDescriber = $this->createMock(TypeDescriberInterface::class);
$innerDescriber->expects(self::once())
->method('describe')
->with(self::identicalTo($objectType));

Comment thread
gnutix marked this conversation as resolved.
$this->describer->setDescriber($innerDescriber);
$this->describer->describe($collectionType, new Schema([]));
}
}
Loading