Skip to content

Commit 9022c23

Browse files
gnutixclaude
andcommitted
Fix ArrayDescriber splitting arrayKey() union into anyOf [array, object]
When TypeInfo resolves `array<T>` (single generic parameter, no explicit key type), the key defaults to `arrayKey()` which is `union(int, string)`. ArrayDescriber was splitting this into separate `array<int, T>` and `array<string, T>` types, producing `anyOf: [{type: array}, {type: object}]` in the OpenAPI schema. This is incorrect — `array<T>` in PHP means a JSON array, not "either array or object". Additionally, when a Traversable object is used as a generic parameter (e.g. `list<MyIterableClass>`), StringTypeResolver wraps it in CollectionType(ObjectType) with no key/value type info. ArrayDescriber then describes this as `array<unknown>`. The fix detects when the wrapped type is an ObjectType and delegates to the chain so ClassDescriber can create a proper `$ref`. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 7f69a49 commit 9022c23

7 files changed

Lines changed: 114 additions & 125 deletions

File tree

src/TypeDescriber/ArrayDescriber.php

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
use OpenApi\Annotations\Schema;
1515
use Symfony\Component\TypeInfo\Type;
1616
use Symfony\Component\TypeInfo\Type\CollectionType;
17+
use Symfony\Component\TypeInfo\TypeIdentifier;
1718

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

34+
// When the key type is arrayKey() (int|string union), the collection has
35+
// no explicit key type (e.g. `array<T>` in PHPDoc). Treat as a JSON array
36+
// rather than splitting into anyOf: [array, object].
37+
$keyTypes = $type->getCollectionKeyType()->getTypes();
38+
if (2 === \count($keyTypes)
39+
&& $keyTypes[0] instanceof Type\BuiltinType
40+
&& $keyTypes[1] instanceof Type\BuiltinType
41+
&& self::isArrayKeyUnion($keyTypes[0]->getTypeIdentifier(), $keyTypes[1]->getTypeIdentifier())
42+
) {
43+
// When a Traversable object is used as a generic parameter
44+
// (e.g. `list<MyTraversableClass>`), StringTypeResolver wraps it in
45+
// CollectionType(ObjectType) with no key/value type info. Unwrap it
46+
// so ClassDescriber can create a proper $ref.
47+
$wrappedType = $type->getWrappedType();
48+
if ($wrappedType instanceof Type\ObjectType) {
49+
$this->describer->describe($wrappedType, $schema, $context);
50+
51+
return;
52+
}
53+
54+
$this->describer->describe(Type::list($type->getCollectionValueType()), $schema, $context);
55+
56+
return;
57+
}
58+
3359
$arrayTypes = array_map(
3460
static fn (Type $keyType): Type => Type::array($type->getCollectionValueType(), $keyType),
3561
$type->getCollectionKeyType()->getTypes()
@@ -47,4 +73,14 @@ public function supports(Type $type, array $context = []): bool
4773
return $type instanceof CollectionType
4874
&& $type->getCollectionKeyType() instanceof Type\UnionType;
4975
}
76+
77+
/**
78+
* Checks whether two type identifiers form an arrayKey() union (int|string),
79+
* regardless of which order TypeInfo emits them.
80+
*/
81+
private static function isArrayKeyUnion(TypeIdentifier $a, TypeIdentifier $b): bool
82+
{
83+
return (TypeIdentifier::INT === $a && TypeIdentifier::STRING === $b)
84+
|| (TypeIdentifier::STRING === $a && TypeIdentifier::INT === $b);
85+
}
5086
}

tests/Functional/Fixtures/GenericTypesController.json

Lines changed: 12 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -126,20 +126,10 @@
126126
}
127127
},
128128
"array": {
129-
"oneOf": [
130-
{
131-
"type": "array",
132-
"items": {
133-
"type": "string"
134-
}
135-
},
136-
{
137-
"type": "object",
138-
"additionalProperties": {
139-
"type": "string"
140-
}
141-
}
142-
]
129+
"type": "array",
130+
"items": {
131+
"type": "string"
132+
}
143133
},
144134
"list": {
145135
"type": "array",
@@ -164,20 +154,10 @@
164154
}
165155
},
166156
"array": {
167-
"oneOf": [
168-
{
169-
"type": "array",
170-
"items": {
171-
"type": "integer"
172-
}
173-
},
174-
{
175-
"type": "object",
176-
"additionalProperties": {
177-
"type": "integer"
178-
}
179-
}
180-
]
157+
"type": "array",
158+
"items": {
159+
"type": "integer"
160+
}
181161
},
182162
"list": {
183163
"type": "array",
@@ -202,20 +182,10 @@
202182
}
203183
},
204184
"array": {
205-
"oneOf": [
206-
{
207-
"type": "array",
208-
"items": {
209-
"$ref": "#/components/schemas/RegularClass"
210-
}
211-
},
212-
{
213-
"type": "object",
214-
"additionalProperties": {
215-
"$ref": "#/components/schemas/RegularClass"
216-
}
217-
}
218-
]
185+
"type": "array",
186+
"items": {
187+
"$ref": "#/components/schemas/RegularClass"
188+
}
219189
},
220190
"list": {
221191
"type": "array",

tests/Functional/FunctionalTest.php

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1164,19 +1164,9 @@ public function testArbitraryArrayModel(): void
11641164
'type' => 'array',
11651165
'items' => [],
11661166
] : [
1167-
'oneOf' => [
1168-
[
1169-
'type' => 'array',
1170-
'items' => [
1171-
'nullable' => true,
1172-
],
1173-
],
1174-
[
1175-
'type' => 'object',
1176-
'additionalProperties' => [
1177-
'nullable' => true,
1178-
],
1179-
],
1167+
'type' => 'array',
1168+
'items' => [
1169+
'nullable' => true,
11801170
],
11811171
],
11821172
'moreThings' => [

tests/Functional/ModelDescriber/Fixtures/TypeInfo/ArrayMixedKeys.json

Lines changed: 4 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,10 @@
44
],
55
"properties": {
66
"mixedArray": {
7-
"oneOf": [
8-
{
9-
"type": "array",
10-
"items": {
11-
"type": "integer"
12-
}
13-
},
14-
{
15-
"type": "object",
16-
"additionalProperties": {
17-
"type": "integer"
18-
}
19-
}
20-
]
7+
"type": "array",
8+
"items": {
9+
"type": "integer"
10+
}
2111
}
2212
},
2313
"type": "object"

tests/Functional/ModelDescriber/Fixtures/TypeInfo/ArrayOfInt.json

Lines changed: 8 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -6,36 +6,16 @@
66
],
77
"properties": {
88
"untypedArray": {
9-
"oneOf": [
10-
{
11-
"type": "array",
12-
"items": {
13-
"nullable": true
14-
}
15-
},
16-
{
17-
"type": "object",
18-
"additionalProperties": {
19-
"nullable": true
20-
}
21-
}
22-
]
9+
"type": "array",
10+
"items": {
11+
"nullable": true
12+
}
2313
},
2414
"arrayOfIntegers": {
25-
"oneOf": [
26-
{
27-
"type": "array",
28-
"items": {
29-
"type": "integer"
30-
}
31-
},
32-
{
33-
"type": "object",
34-
"additionalProperties": {
35-
"type": "integer"
36-
}
37-
}
38-
]
15+
"type": "array",
16+
"items": {
17+
"type": "integer"
18+
}
3919
},
4020
"listOfIntegers": {
4121
"type": "array",

tests/Functional/ModelDescriber/Fixtures/TypeInfo/ArrayOfString.json

Lines changed: 8 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -6,36 +6,16 @@
66
],
77
"properties": {
88
"untypedArray": {
9-
"oneOf": [
10-
{
11-
"type": "array",
12-
"items": {
13-
"nullable": true
14-
}
15-
},
16-
{
17-
"type": "object",
18-
"additionalProperties": {
19-
"nullable": true
20-
}
21-
}
22-
]
9+
"type": "array",
10+
"items": {
11+
"nullable": true
12+
}
2313
},
2414
"arrayOfStrings": {
25-
"oneOf": [
26-
{
27-
"type": "array",
28-
"items": {
29-
"type": "string"
30-
}
31-
},
32-
{
33-
"type": "object",
34-
"additionalProperties": {
35-
"type": "string"
36-
}
37-
}
38-
]
15+
"type": "array",
16+
"items": {
17+
"type": "string"
18+
}
3919
},
4020
"listOfStrings": {
4121
"type": "array",

tests/TypeDescriber/ArrayDescriberTest.php

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
namespace Nelmio\ApiDocBundle\Tests\TypeDescriber;
1313

1414
use Nelmio\ApiDocBundle\TypeDescriber\ArrayDescriber;
15+
use Nelmio\ApiDocBundle\TypeDescriber\TypeDescriberInterface;
1516
use OpenApi\Annotations\Schema;
1617
use PHPUnit\Framework\TestCase;
1718
use Symfony\Component\TypeInfo\Type;
@@ -47,4 +48,46 @@ public static function provideInvalidCollectionTypes(): \Generator
4748
yield [Type::list()];
4849
yield [Type::dict()];
4950
}
51+
52+
/**
53+
* When the key type is arrayKey() (int|string union), the describer should
54+
* treat the collection as a list rather than splitting into anyOf [array, object].
55+
*/
56+
public function testArrayKeyUnionIsTreatedAsList(): void
57+
{
58+
$innerDescriber = $this->createMock(TypeDescriberInterface::class);
59+
$innerDescriber->expects(self::once())
60+
->method('describe')
61+
->with(self::callback(static function (Type $type): bool {
62+
// Should delegate a list(string) — i.e. CollectionType with int key and string value
63+
return $type instanceof CollectionType
64+
&& $type->isList()
65+
&& 'string' === (string) $type->getCollectionValueType();
66+
}));
67+
68+
$this->describer->setDescriber($innerDescriber);
69+
70+
// array<string> is resolved by TypeInfo as CollectionType with arrayKey() union key
71+
$type = Type::array(Type::string());
72+
$this->describer->describe($type, new Schema([]));
73+
}
74+
75+
/**
76+
* When a Traversable object is auto-wrapped in CollectionType(ObjectType),
77+
* the describer should unwrap it and delegate the ObjectType directly.
78+
*/
79+
public function testTraversableObjectIsUnwrapped(): void
80+
{
81+
$objectType = Type::object(\ArrayObject::class);
82+
// Simulate what StringTypeResolver does: wrap in CollectionType(ObjectType)
83+
$collectionType = new CollectionType($objectType);
84+
85+
$innerDescriber = $this->createMock(TypeDescriberInterface::class);
86+
$innerDescriber->expects(self::once())
87+
->method('describe')
88+
->with(self::identicalTo($objectType));
89+
90+
$this->describer->setDescriber($innerDescriber);
91+
$this->describer->describe($collectionType, new Schema([]));
92+
}
5093
}

0 commit comments

Comments
 (0)