diff --git a/src/Type/TypeInfoTypeResolver.php b/src/Type/TypeInfoTypeResolver.php index 950a8a180..146346182 100644 --- a/src/Type/TypeInfoTypeResolver.php +++ b/src/Type/TypeInfoTypeResolver.php @@ -10,6 +10,7 @@ use OpenApi\Annotations as OA; use OpenApi\Context; use OpenApi\Generator; +use OpenApi\TypeResolverInterface; use PHPStan\PhpDocParser\Ast\PhpDoc\ParamTagValueNode; use PHPStan\PhpDocParser\Ast\PhpDoc\ReturnTagValueNode; use PHPStan\PhpDocParser\Ast\PhpDoc\VarTagValueNode; @@ -32,7 +33,6 @@ use Symfony\Component\TypeInfo\Type\ObjectType; use Symfony\Component\TypeInfo\Type\UnionType; use Symfony\Component\TypeInfo\TypeContext\TypeContextFactory; -use Symfony\Component\TypeInfo\TypeIdentifier; use Symfony\Component\TypeInfo\TypeResolver\ReflectionTypeResolver; class TypeInfoTypeResolver extends AbstractTypeResolver @@ -93,7 +93,11 @@ protected function setSchemaType(OA\Schema $schema, Type $type, Analysis $analys if ($type instanceof UnionType) { if ($allBuiltin) { - $schema->type = array_map(static fn (Type $t): string => (string) $t, $types); + $mappableTypes = array_values(array_filter( + array_map(static fn (Type $t): string => (string) $t, $types), + $this->hasOpenApiType(...), + )); + $schema->type = [] === $mappableTypes ? Generator::UNDEFINED : $mappableTypes; } else { $builtinTypes = array_filter($types, static fn (Type $t): bool => $t instanceof BuiltinType); $otherTypes = array_filter($types, static fn (Type $t): bool => !$t instanceof BuiltinType); @@ -140,7 +144,7 @@ protected function setSchemaType(OA\Schema $schema, Type $type, Analysis $analys } } else { if ($type instanceof BuiltinType) { - if (!$type->isIdentifiedBy(TypeIdentifier::MIXED)) { + if ($this->hasOpenApiType((string) $type)) { $schema->type = (string) $type; } } elseif ($type instanceof ObjectType) { @@ -190,6 +194,23 @@ protected function setSchemaType(OA\Schema $schema, Type $type, Analysis $analys return $schema; } + /** + * Checks that the given type has an OpenAPI representation. + * + * Types such as mixed, callable, resource and iterable have none; callers leave the schema open for those instead of emitting an invalid type. + */ + protected function hasOpenApiType(string $native): bool + { + $native = strtolower($native); + + // NATIVE_TYPE_MAP maps "mixed" to "mixed", which is not a valid OpenAPI type, so mixed has no representation. + if ('mixed' === $native) { + return false; + } + + return 'null' === $native || array_key_exists($native, TypeResolverInterface::NATIVE_TYPE_MAP); + } + /**645 1050272 02 1268 0026220 00 * @param \ReflectionParameter|\ReflectionProperty|\ReflectionMethod $reflector */ diff --git a/tests/Fixtures/PHP/DocblockAndTypehintTypes.php b/tests/Fixtures/PHP/DocblockAndTypehintTypes.php index 7cf908f09..bc516566a 100644 --- a/tests/Fixtures/PHP/DocblockAndTypehintTypes.php +++ b/tests/Fixtures/PHP/DocblockAndTypehintTypes.php @@ -137,6 +137,18 @@ class DocblockAndTypehintTypes #[OAT\Property] public array $intKeyedMap; + /** + * @var list + */ + #[OAT\Property] + public array $unmappableList; + + /** + * @var array + */ + #[OAT\Property] + public array $unmappableMap; + /** * @var int|string */ diff --git a/tests/Fixtures/TypedProperties.php b/tests/Fixtures/TypedProperties.php index 66f676ffc..977179d90 100644 --- a/tests/Fixtures/TypedProperties.php +++ b/tests/Fixtures/TypedProperties.php @@ -8,7 +8,7 @@ use OpenApi\Attributes as OAT; -#[OAT\Schema()] +#[OAT\Schema] class TypedProperties { #[OAT\Property] @@ -90,4 +90,26 @@ class TypedProperties */ #[OAT\Property] public array $stringMap; + + /** + * A map whose value type has no OpenAPI representation. + * + * @var array + */ + #[OAT\Property] + public array $unmappableMap; + + /** + * A map whose value type is mixed, which has no OpenAPI representation. + * + * @var array + */ + #[OAT\Property] + public array $mixedMap; + + /** + * A mixed value, which has no concrete OpenAPI type and stays an open "any value" schema. + */ + #[OAT\Property] + public mixed $mixedValue; } diff --git a/tests/Processors/AugmentPropertiesTest.php b/tests/Processors/AugmentPropertiesTest.php index d4f4b9518..35da65ba2 100644 --- a/tests/Processors/AugmentPropertiesTest.php +++ b/tests/Processors/AugmentPropertiesTest.php @@ -178,6 +178,9 @@ public function testTypedProperties(): void $staticNullableString, $nativeArray, $stringMap, + $unmappableMap, + $mixedMap, + $mixedValue, ] = $analysis->openapi->components->schemas[0]->properties; $this->assertName($stringType, [ @@ -260,6 +263,18 @@ public function testTypedProperties(): void 'property' => Generator::UNDEFINED, 'type' => Generator::UNDEFINED, ]); + $this->assertName($unmappableMap, [ + 'property' => Generator::UNDEFINED, + 'type' => Generator::UNDEFINED, + ]); + $this->assertName($mixedMap, [ + 'property' => Generator::UNDEFINED, + 'type' => Generator::UNDEFINED, + ]); + $this->assertName($mixedValue, [ + 'property' => Generator::UNDEFINED, + 'type' => Generator::UNDEFINED, + ]); $this->processorPipeline([new AugmentProperties()])->process($analysis); @@ -360,6 +375,34 @@ public function testTypedProperties(): void $this->assertFalse(Generator::isDefault($stringMap->additionalProperties)); $this->assertSame('string', $stringMap->additionalProperties->type); $this->assertTrue(Generator::isDefault($stringMap->items)); + + $this->assertName($unmappableMap, [ + 'property' => 'unmappableMap', + 'type' => 'object', + ]); + $this->assertFalse(Generator::isDefault($unmappableMap->additionalProperties)); + $this->assertTrue( + Generator::isDefault($unmappableMap->additionalProperties->type), + 'callable has no OpenAPI representation, so additionalProperties stays open instead of emitting type: callable', + ); + + $this->assertName($mixedMap, [ + 'property' => 'mixedMap', + 'type' => 'object', + ]); + $this->assertFalse(Generator::isDefault($mixedMap->additionalProperties)); + $this->assertTrue( + Generator::isDefault($mixedMap->additionalProperties->type), + 'mixed has no OpenAPI representation, so additionalProperties stays open instead of emitting type: mixed', + ); + + $this->assertName($mixedValue, [ + 'property' => 'mixedValue', + ]); + $this->assertTrue( + Generator::isDefault($mixedValue->type), + 'mixed is the OpenAPI any value, so the property stays an open schema with no type instead of emitting type: mixed', + ); } public function testComplexVarTypeDescription(): void diff --git a/tests/Type/TypeResolverTest.php b/tests/Type/TypeResolverTest.php index ac9822390..68fbf923e 100644 --- a/tests/Type/TypeResolverTest.php +++ b/tests/Type/TypeResolverTest.php @@ -49,6 +49,8 @@ public static function resolverAugmentCases(): iterable 'type-info:stringmap' => '{ "type": "object", "additionalProperties": { "type": "string" }, "property": "stringMap" }', 'legacy:intkeyedmap' => '{ "type": "array", "items": {}, "property": "intKeyedMap" }', 'type-info:intkeyedmap' => '{ "type": "object", "additionalProperties": { "type": "string" }, "property": "intKeyedMap" }', + 'type-info:unmappablelist' => '{ "type": "array", "items": {}, "property": "unmappableList" }', + 'type-info:unmappablemap' => '{ "type": "object", "additionalProperties": {}, "property": "unmappableMap" }', 'uniontype' => '{ "property": "unionType" }', 'promotedstring' => '{ "type": "string", "property": "promotedString" }', 'legacy:mixedunion' => '{ "example": "My value", "property": "mixedUnion" }', @@ -98,6 +100,8 @@ public static function resolverAugmentCases(): iterable 'type-info:stringmap' => '{ "type": "object", "additionalProperties": { "type": "string" }, "property": "stringMap" }', 'legacy:intkeyedmap' => '{ "type": "array", "items": {}, "property": "intKeyedMap" }', 'type-info:intkeyedmap' => '{ "type": "object", "additionalProperties": { "type": "string" }, "property": "intKeyedMap" }', + 'type-info:unmappablelist' => '{ "type": "array", "items": {}, "property": "unmappableList" }', + 'type-info:unmappablemap' => '{ "type": "object", "additionalProperties": {}, "property": "unmappableMap" }', 'legacy:uniontype' => '{ "property": "unionType" }', 'type-info:uniontype' => '{ "type": [ "integer", "string" ], "property": "unionType" }', 'promotedstring' => '{ "type": "string", "property": "promotedString" }',