Skip to content

Commit 3fb5d91

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 e651ed9 commit 3fb5d91

2 files changed

Lines changed: 68 additions & 0 deletions

File tree

src/TypeDescriber/ArrayDescriber.php

Lines changed: 25 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,30 @@ 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 && TypeIdentifier::INT === $keyTypes[0]->getTypeIdentifier()
40+
&& $keyTypes[1] instanceof Type\BuiltinType && TypeIdentifier::STRING === $keyTypes[1]->getTypeIdentifier()
41+
) {
42+
// When a Traversable object is used as a generic parameter
43+
// (e.g. `list<MyTraversableClass>`), StringTypeResolver wraps it in
44+
// CollectionType(ObjectType) with no key/value type info. Unwrap it
45+
// so ClassDescriber can create a proper $ref.
46+
$wrappedType = $type->getWrappedType();
47+
if ($wrappedType instanceof Type\ObjectType) {
48+
$this->describer->describe($wrappedType, $schema, $context);
49+
50+
return;
51+
}
52+
53+
$this->describer->describe(Type::list($type->getCollectionValueType()), $schema, $context);
54+
55+
return;
56+
}
57+
3358
$arrayTypes = array_map(
3459
static fn (Type $keyType): Type => Type::array($type->getCollectionValueType(), $keyType),
3560
$type->getCollectionKeyType()->getTypes()

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)