From 97b25e45d2fc9bcb7915ca7827088fd309e96c76 Mon Sep 17 00:00:00 2001 From: Jille Date: Mon, 29 Jun 2026 09:52:38 +0200 Subject: [PATCH] feat: resolve phpdoc array shapes to object schemas Resolve array{...} to object schemas (properties/required) instead of collapsing them to a homogeneous map. --- src/Type/TypeInfoTypeResolver.php | 98 ++++++++++++++++--- .../Fixtures/PHP/DocblockAndTypehintTypes.php | 48 +++++++++ tests/Fixtures/TypedProperties.php | 8 ++ tests/Processors/AugmentPropertiesTest.php | 11 +++ tests/Type/TypeResolverTest.php | 20 +++- 5 files changed, 170 insertions(+), 15 deletions(-) diff --git a/src/Type/TypeInfoTypeResolver.php b/src/Type/TypeInfoTypeResolver.php index 146346182..b9671a871 100644 --- a/src/Type/TypeInfoTypeResolver.php +++ b/src/Type/TypeInfoTypeResolver.php @@ -25,6 +25,7 @@ use Radebatz\TypeInfoExtras\TypeResolver\StringTypeResolver; use Symfony\Component\TypeInfo\Exception\UnsupportedException; use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\Type\ArrayShapeType; use Symfony\Component\TypeInfo\Type\BuiltinType; use Symfony\Component\TypeInfo\Type\CollectionType; use Symfony\Component\TypeInfo\Type\CompositeTypeInterface; @@ -156,22 +157,13 @@ protected function setSchemaType(OA\Schema $schema, Type $type, Analysis $analys $schema->maximum = $type->getTo(); } elseif ($type instanceof ExplicitType) { $schema->type = $type->getTypeIdentifier()->value; + } elseif ($type instanceof ArrayShapeType && [] !== $type->getShape()) { + // array{a: int, b?: string} → object with named properties; array{0: T, 1: U} → positional array + $this->setSchemaTypeFromArrayShape($schema, $type, $analysis); } elseif ($type instanceof CollectionType) { if ($type->isList() || $type->getCollectionKeyType() instanceof UnionType) { // list, array, T[] → ordered list - $schema->type = 'array'; - - if (Generator::isDefault($schema->items)) { - $schema->items = new OA\Items(['_context' => new Context(['generated' => true], $schema->_context)]); - $this->setSchemaType($schema->items, $type->getCollectionValueType(), $analysis); - $this->type2ref($schema->items, $analysis); - $analysis->addAnnotation($schema->items, $schema->items->_context); - } elseif (Generator::isDefault($schema->items->type, $schema->items->oneOf, $schema->items->allOf, $schema->items->anyOf)) { - $this->setSchemaType($schema->items, $type->getCollectionValueType(), $analysis); - $this->type2ref($schema->items, $analysis); - } - - $this->mapNativeType($schema->items, $schema->items->type); + $this->setListSchema($schema, $type->getCollectionValueType(), $analysis); } else { // explicit key type (e.g. array) → map $schema->type = 'object'; @@ -194,6 +186,86 @@ protected function setSchemaType(OA\Schema $schema, Type $type, Analysis $analys return $schema; } + protected function setSchemaTypeFromArrayShape(OA\Schema $schema, ArrayShapeType $type, Analysis $analysis): void + { + $shape = $type->getShape(); + + // A list-shaped array (array{T, U} or array{0: T, 1: U}) is a positional list, not a keyed object. + if (array_is_list($shape)) { + $this->setListSchema($schema, $type->getCollectionValueType(), $analysis); + + return; + } + + $schema->type = 'object'; + + $properties = []; + $required = []; + foreach ($shape as $name => $member) { + $propertyName = (string) $name; + $property = new OA\Property([ + 'property' => $propertyName, + '_context' => new Context(['generated' => true], $schema->_context), + ]); + $this->setSchemaType($property, $member['type'], $analysis); + $this->type2ref($property, $analysis); + $this->mapNativeType($property, $property->type); + $analysis->addAnnotation($property, $property->_context); + + $properties[] = $property; + + if (!($member['optional'] ?? false)) { + $required[] = $propertyName; + } + } + + $schema->properties = $properties; + + if ([] !== $required) { + $schema->required = $required; + } + + /* + * An unsealed shape permits extra entries. + * For example array{a: int, ...} or array{a: int, ...}. + * The schema therefore allows additional properties. + * + * The rest value type (...) is left open, not emitted. + * Symfony's type-info resolves it inconsistently across versions. + * The same ... comes back as string, int|string, or unresolved. + * Emitting it would be unstable and sometimes invalid. + * + * A sealed shape has a null extra value type. + * A non-null one means the shape is open. + */ + if ($type->getExtraValueType() instanceof Type) { + $schema->additionalProperties = new OA\AdditionalProperties(['_context' => new Context(['generated' => true], $schema->_context)]); + $analysis->addAnnotation($schema->additionalProperties, $schema->additionalProperties->_context); + } + } + + /** + * Emits an ordered-list schema (type: array) whose items resolve from the given value type. + * + * Used for both collection lists (list, array, T[]) and positional array shapes (array{0: T, 1: U}). + */ + protected function setListSchema(OA\Schema $schema, Type $valueType, Analysis $analysis): void + { + $schema->type = 'array'; + + if (Generator::isDefault($schema->items)) { + $schema->items = new OA\Items(['_context' => new Context(['generated' => true], $schema->_context)]); + $this->setSchemaType($schema->items, $valueType, $analysis); + $this->type2ref($schema->items, $analysis); + $analysis->addAnnotation($schema->items, $schema->items->_context); + } elseif (Generator::isDefault($schema->items->type, $schema->items->oneOf, $schema->items->allOf, $schema->items->anyOf)) { + $this->setSchemaType($schema->items, $valueType, $analysis); + $this->type2ref($schema->items, $analysis); + } + + $this->mapNativeType($schema->items, $schema->items->type); + } + /** * Checks that the given type has an OpenAPI representation. * diff --git a/tests/Fixtures/PHP/DocblockAndTypehintTypes.php b/tests/Fixtures/PHP/DocblockAndTypehintTypes.php index bc516566a..42989695c 100644 --- a/tests/Fixtures/PHP/DocblockAndTypehintTypes.php +++ b/tests/Fixtures/PHP/DocblockAndTypehintTypes.php @@ -125,6 +125,54 @@ class DocblockAndTypehintTypes #[OAT\Property] public array $arrayShape; + /** + * @var array{foo: bool, bar?: int} + */ + #[OAT\Property] + public array $optionalArrayShape; + + /** + * @var array{foo: bool, ...} + */ + #[OAT\Property] + public array $openArrayShape; + + /** + * @var array{0: int, 1: int} + */ + #[OAT\Property] + public array $positionalArrayShape; + + /** + * @var array{0: int, 1: int} + */ + #[OAT\Property(items: new OAT\Items(example: 42))] + public array $positionalArrayShapeExplicit; + + /** + * @var array{nested: DocblockAndTypehintTypes, tags: list} + */ + #[OAT\Property] + public array $objectArrayShape; + + /** + * @var array{a: array{b: int}} + */ + #[OAT\Property] + public array $nestedArrayShape; + + /** + * @var array{foo: ?string} + */ + #[OAT\Property] + public array $nullableMemberArrayShape; + + /** + * @var array{} + */ + #[OAT\Property] + public array $emptyArrayShape; + /** * @var array */ diff --git a/tests/Fixtures/TypedProperties.php b/tests/Fixtures/TypedProperties.php index 977179d90..5c2c22881 100644 --- a/tests/Fixtures/TypedProperties.php +++ b/tests/Fixtures/TypedProperties.php @@ -91,6 +91,14 @@ class TypedProperties #[OAT\Property] public array $stringMap; + /** + * An array shape with a required and an optional member. + * + * @var array{id: int, name?: string} + */ + #[OAT\Property] + public array $arrayShape; + /** * A map whose value type has no OpenAPI representation. * diff --git a/tests/Processors/AugmentPropertiesTest.php b/tests/Processors/AugmentPropertiesTest.php index 35da65ba2..f7fda0f4f 100644 --- a/tests/Processors/AugmentPropertiesTest.php +++ b/tests/Processors/AugmentPropertiesTest.php @@ -178,6 +178,7 @@ public function testTypedProperties(): void $staticNullableString, $nativeArray, $stringMap, + $arrayShape, $unmappableMap, $mixedMap, $mixedValue, @@ -376,6 +377,16 @@ public function testTypedProperties(): void $this->assertSame('string', $stringMap->additionalProperties->type); $this->assertTrue(Generator::isDefault($stringMap->items)); + $this->assertName($arrayShape, [ + 'property' => 'arrayShape', + 'type' => 'object', + ]); + $this->assertTrue(Generator::isDefault($arrayShape->additionalProperties)); + $this->assertSame(['id'], $arrayShape->required); + [$id, $name] = $arrayShape->properties; + $this->assertName($id, ['property' => 'id', 'type' => 'integer']); + $this->assertName($name, ['property' => 'name', 'type' => 'string']); + $this->assertName($unmappableMap, [ 'property' => 'unmappableMap', 'type' => 'object', diff --git a/tests/Type/TypeResolverTest.php b/tests/Type/TypeResolverTest.php index 68fbf923e..cb880e00c 100644 --- a/tests/Type/TypeResolverTest.php +++ b/tests/Type/TypeResolverTest.php @@ -44,7 +44,15 @@ public static function resolverAugmentCases(): iterable 'positiveint' => '{ "type": "integer", "maximum": 9223372036854775807, "minimum": 1, "property": "positiveInt" }', 'nonzeroint' => '{ "type": "integer", "not": { "enum": [ 0 ] }, "property": "nonZeroInt" }', 'legacy:arrayshape' => '{ "type": "array", "items": { "type": "boolean" }, "property": "arrayShape" }', - 'type-info:arrayshape' => '{ "type": "object", "additionalProperties": { "type": "boolean" }, "property": "arrayShape" }', + 'type-info:arrayshape' => '{ "type": "object", "properties": { "foo": { "type": "boolean" } }, "required": [ "foo" ], "property": "arrayShape" }', + 'type-info:optionalarrayshape' => '{ "type": "object", "properties": { "bar": { "type": "integer" }, "foo": { "type": "boolean" } }, "required": [ "foo" ], "property": "optionalArrayShape" }', + 'type-info:openarrayshape' => '{ "type": "object", "properties": { "foo": { "type": "boolean" } }, "required": [ "foo" ], "additionalProperties": {}, "property": "openArrayShape" }', + 'type-info:positionalarrayshape' => '{ "type": "array", "items": { "type": "integer" }, "property": "positionalArrayShape" }', + 'type-info:positionalarrayshapeexplicit' => '{ "type": "array", "items": { "type": "integer", "example": 42 }, "property": "positionalArrayShapeExplicit" }', + 'type-info:emptyarrayshape' => '{ "type": "array", "items": {}, "property": "emptyArrayShape" }', + 'type-info:objectarrayshape' => '{ "type": "object", "properties": { "nested": { "$ref": "#/components/schemas/DocblockAndTypehintTypes" }, "tags": { "type": "array", "items": { "type": "string" } } }, "required": [ "nested", "tags" ], "property": "objectArrayShape" }', + 'type-info:nestedarrayshape' => '{ "type": "object", "properties": { "a": { "type": "object", "properties": { "b": { "type": "integer" } }, "required": [ "b" ] } }, "required": [ "a" ], "property": "nestedArrayShape" }', + 'type-info:nullablememberarrayshape' => '{ "type": "object", "properties": { "foo": { "type": "string", "nullable": true } }, "required": [ "foo" ], "property": "nullableMemberArrayShape" }', 'legacy:stringmap' => '{ "type": "array", "items": {}, "property": "stringMap" }', 'type-info:stringmap' => '{ "type": "object", "additionalProperties": { "type": "string" }, "property": "stringMap" }', 'legacy:intkeyedmap' => '{ "type": "array", "items": {}, "property": "intKeyedMap" }', @@ -95,7 +103,15 @@ public static function resolverAugmentCases(): iterable 'positiveint' => '{ "type": "integer", "maximum": 9223372036854775807, "minimum": 1, "property": "positiveInt" }', 'nonzeroint' => '{ "type": "integer", "not": { "const": 0 }, "property": "nonZeroInt" } ', 'legacy:arrayshape' => '{ "type": "array", "items": { "type": "boolean" }, "property": "arrayShape" }', - 'type-info:arrayshape' => '{ "type": "object", "additionalProperties": { "type": "boolean" }, "property": "arrayShape" }', + 'type-info:arrayshape' => '{ "type": "object", "properties": { "foo": { "type": "boolean" } }, "required": [ "foo" ], "property": "arrayShape" }', + 'type-info:optionalarrayshape' => '{ "type": "object", "properties": { "bar": { "type": "integer" }, "foo": { "type": "boolean" } }, "required": [ "foo" ], "property": "optionalArrayShape" }', + 'type-info:openarrayshape' => '{ "type": "object", "properties": { "foo": { "type": "boolean" } }, "required": [ "foo" ], "additionalProperties": {}, "property": "openArrayShape" }', + 'type-info:positionalarrayshape' => '{ "type": "array", "items": { "type": "integer" }, "property": "positionalArrayShape" }', + 'type-info:positionalarrayshapeexplicit' => '{ "type": "array", "items": { "type": "integer", "example": 42 }, "property": "positionalArrayShapeExplicit" }', + 'type-info:emptyarrayshape' => '{ "type": "array", "items": {}, "property": "emptyArrayShape" }', + 'type-info:objectarrayshape' => '{ "type": "object", "properties": { "nested": { "$ref": "#/components/schemas/DocblockAndTypehintTypes" }, "tags": { "type": "array", "items": { "type": "string" } } }, "required": [ "nested", "tags" ], "property": "objectArrayShape" }', + 'type-info:nestedarrayshape' => '{ "type": "object", "properties": { "a": { "type": "object", "properties": { "b": { "type": "integer" } }, "required": [ "b" ] } }, "required": [ "a" ], "property": "nestedArrayShape" }', + 'type-info:nullablememberarrayshape' => '{ "type": "object", "properties": { "foo": { "type": [ "null", "string" ] } }, "required": [ "foo" ], "property": "nullableMemberArrayShape" }', 'legacy:stringmap' => '{ "type": "array", "items": {}, "property": "stringMap" }', 'type-info:stringmap' => '{ "type": "object", "additionalProperties": { "type": "string" }, "property": "stringMap" }', 'legacy:intkeyedmap' => '{ "type": "array", "items": {}, "property": "intKeyedMap" }',