diff --git a/src/TypeDescriber/ArrayDescriber.php b/src/TypeDescriber/ArrayDescriber.php index fcb02afe7..70ac5fbbd 100644 --- a/src/TypeDescriber/ArrayDescriber.php +++ b/src/TypeDescriber/ArrayDescriber.php @@ -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 @@ -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` 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`), 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; + } + $arrayTypes = array_map( static fn (Type $keyType): Type => Type::array($type->getCollectionValueType(), $keyType), $type->getCollectionKeyType()->getTypes() @@ -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); + } } diff --git a/tests/Functional/Controller/JMSController.php b/tests/Functional/Controller/JMSController.php index edce7f5f9..03994fca7 100644 --- a/tests/Functional/Controller/JMSController.php +++ b/tests/Functional/Controller/JMSController.php @@ -132,8 +132,7 @@ public function minUserNestedAction(): void response: 200, description: 'Success', content: new Model(type: Article81::class) - ) - ] + )] public function enum(): void { } @@ -143,8 +142,7 @@ public function enum(): void response: 200, description: 'Success', content: new Model(type: JMSAbstractUser::class) - ) - ] + )] public function discriminatorMapAction(): void { } @@ -154,8 +152,7 @@ public function discriminatorMapAction(): void response: 200, description: 'Success', content: new Model(type: JMSEnum::class) - ) - ] + )] public function enumArrayAction(): void { } @@ -165,8 +162,7 @@ public function enumArrayAction(): void response: 200, description: 'Success', content: new Model(type: JMSIgnoredProperty::class) - ) - ] + )] public function ignoredProperty(): void { } diff --git a/tests/Functional/Fixtures/GenericTypesController.json b/tests/Functional/Fixtures/GenericTypesController.json index a591482a3..fb7fed842 100644 --- a/tests/Functional/Fixtures/GenericTypesController.json +++ b/tests/Functional/Fixtures/GenericTypesController.json @@ -126,20 +126,10 @@ } }, "array": { - "oneOf": [ - { - "type": "array", - "items": { - "type": "string" - } - }, - { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - ] + "type": "array", + "items": { + "type": "string" + } }, "list": { "type": "array", @@ -164,20 +154,10 @@ } }, "array": { - "oneOf": [ - { - "type": "array", - "items": { - "type": "integer" - } - }, - { - "type": "object", - "additionalProperties": { - "type": "integer" - } - } - ] + "type": "array", + "items": { + "type": "integer" + } }, "list": { "type": "array", @@ -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", diff --git a/tests/Functional/FunctionalTest.php b/tests/Functional/FunctionalTest.php index 210ff0e4a..f1ce3d1da 100644 --- a/tests/Functional/FunctionalTest.php +++ b/tests/Functional/FunctionalTest.php @@ -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' => [ diff --git a/tests/Functional/ModelDescriber/Fixtures/TypeInfo/ArrayMixedKeys.json b/tests/Functional/ModelDescriber/Fixtures/TypeInfo/ArrayMixedKeys.json index c89b12591..c3a2171ec 100644 --- a/tests/Functional/ModelDescriber/Fixtures/TypeInfo/ArrayMixedKeys.json +++ b/tests/Functional/ModelDescriber/Fixtures/TypeInfo/ArrayMixedKeys.json @@ -4,20 +4,10 @@ ], "properties": { "mixedArray": { - "oneOf": [ - { - "type": "array", - "items": { - "type": "integer" - } - }, - { - "type": "object", - "additionalProperties": { - "type": "integer" - } - } - ] + "type": "array", + "items": { + "type": "integer" + } } }, "type": "object" diff --git a/tests/Functional/ModelDescriber/Fixtures/TypeInfo/ArrayOfInt.json b/tests/Functional/ModelDescriber/Fixtures/TypeInfo/ArrayOfInt.json index 88f7ba11f..08329dfc1 100644 --- a/tests/Functional/ModelDescriber/Fixtures/TypeInfo/ArrayOfInt.json +++ b/tests/Functional/ModelDescriber/Fixtures/TypeInfo/ArrayOfInt.json @@ -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", diff --git a/tests/Functional/ModelDescriber/Fixtures/TypeInfo/ArrayOfString.json b/tests/Functional/ModelDescriber/Fixtures/TypeInfo/ArrayOfString.json index 90b8bca4e..face49d70 100644 --- a/tests/Functional/ModelDescriber/Fixtures/TypeInfo/ArrayOfString.json +++ b/tests/Functional/ModelDescriber/Fixtures/TypeInfo/ArrayOfString.json @@ -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", diff --git a/tests/TypeDescriber/ArrayDescriberTest.php b/tests/TypeDescriber/ArrayDescriberTest.php index a1a6090da..6ba7c2de9 100644 --- a/tests/TypeDescriber/ArrayDescriberTest.php +++ b/tests/TypeDescriber/ArrayDescriberTest.php @@ -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; @@ -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(); + })); + + $this->describer->setDescriber($innerDescriber); + + // array 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)); + + $this->describer->setDescriber($innerDescriber); + $this->describer->describe($collectionType, new Schema([])); + } }