diff --git a/.doctor-rst.yaml b/.doctor-rst.yaml index ae39fbe61..72fd4a295 100644 --- a/.doctor-rst.yaml +++ b/.doctor-rst.yaml @@ -53,16 +53,16 @@ rules: # master versionadded_directive_major_version: - major_version: 6 + major_version: 7 versionadded_directive_min_version: - min_version: '6.0' + min_version: '7.0' deprecated_directive_major_version: - major_version: 5 + major_version: 6 deprecated_directive_min_version: - min_version: '5.0' + min_version: '6.0' # do not report as violation whitelist: diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index ee4c1157c..9f192617e 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -2,10 +2,20 @@ declare(strict_types=1); +/* + * This file is part of the NelmioApiDocBundle package. + * + * (c) Nelmio + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + $finder = (new PhpCsFixer\Finder()) ->in(__DIR__) ->exclude('var') - ->exclude('tests/Functional/cache'); + ->exclude('tests/Functional/cache') + ->exclude('tests/Functional/ModelDescriber/Fixtures'); return (new PhpCsFixer\Config()) ->setRules([ @@ -13,13 +23,13 @@ '@PHP84Migration' => true, 'header_comment' => [ 'header' => <<
false, ]) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9cc5b94c5..23834174f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # CHANGELOG +## 4.35.0 +* Added support for the symfony/type-info component +```yaml +nelmio_api_doc: + type_info: true +``` + ## 4.34.0 * Changed minimum Symfony version for 7.x from 7.0 to 7.1 diff --git a/config/services.xml b/config/services.xml index c9789b580..f99aacaf3 100644 --- a/config/services.xml +++ b/config/services.xml @@ -161,6 +161,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/faq.rst b/docs/faq.rst index 496eb03be..5393dc493 100644 --- a/docs/faq.rst +++ b/docs/faq.rst @@ -262,4 +262,85 @@ Note, however, that a ``type="object"`` will still read all a models properties. class SomeCollection implements \IteratorAggregate { // ... - } \ No newline at end of file + } + +PropertyInfo component was unable to guess the type +--------------------------------------------------- + +Q: I have a property that is not recognized. How can I specify the type? + +.. tip:: + + Enable the `TypeInfo component`_ in your configuration to improve automatic type guessing: + + .. code-block:: yaml + + nelmio_api_doc: + type_info: true + # ... + +.. versionadded:: 7.2 + + The `TypeInfo component`_ was introduced as a stable feature in Symfony 7.2. + +A: If you want to customize the documentation of an object's property, you can use the ``#[OA\Property]`` attribute or annotate the property with ``@var``:: + +.. configuration-block:: + + .. code-block:: php-annotations + + use Nelmio\ApiDocBundle\Attribute\Model; + use OpenApi\Annotations as OA; + + class User + { + /** + * @var int + * @OA\Property(description="The unique identifier of the user.") + */ + public $id; + + /** + * @OA\Property(type="string", maxLength=255) + */ + public $username; + + /** + * @OA\Property(ref=@Model(type=User::class)) + */ + public $friend; + + /** + * @OA\Property(description="This is my coworker!") + */ + public setCoworker(User $coworker) { + // ... + } + } + + .. code-block:: php-attributes + + use Nelmio\ApiDocBundle\Attribute\Model; + use OpenApi\Attributes as OA; + + class User + { + /** + * @var int + */ + #[OA\Property(description: 'The unique identifier of the user.')] + public $id; + + #[OA\Property(type: 'string', maxLength: 255)] + public $username; + + #[OA\Property(ref: new Model(type: User::class))] + public $friend; + + #[OA\Property(description: 'This is my coworker!')] + public setCoworker(User $coworker) { + // ... + } + } + +.. _`TypeInfo component`: https://symfony.com/doc/current/components/type_info.html diff --git a/docs/index.rst b/docs/index.rst index deb90afc9..9f3e2c381 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -524,6 +524,20 @@ General PHP objects When using the JMS serializer combined with `willdurand/Hateoas`_ (and the `BazingaHateoasBundle`_), HATEOAS metadata are automatically extracted +.. tip:: + + Enable the `TypeInfo component`_ in your configuration to improve automatic type guessing: + + .. code-block:: yaml + + nelmio_api_doc: + type_info: true + # ... + +.. versionadded:: 7.2 + + The `TypeInfo component`_ was introduced as a stable feature in Symfony 7.2. + If you want to customize the documentation of an object's property, you can use ``#[OA\Property]``:: @@ -601,12 +615,13 @@ If you need more complex features, take a look at: alternative_names customization commands - faq security symfony_attributes + faq .. _`SwaggerPHP examples`: https://github.com/zircote/swagger-php/tree/master/Examples .. _`Symfony PropertyInfo component`: https://symfony.com/doc/current/components/property_info.html +.. _`TypeInfo component`: https://symfony.com/doc/current/components/type_info.html .. _`willdurand/Hateoas`: https://github.com/willdurand/Hateoas .. _`BazingaHateoasBundle`: https://github.com/willdurand/BazingaHateoasBundle .. _`JMS serializer`: https://jmsyst.com/libs/serializer diff --git a/docs/symfony_attributes.rst b/docs/symfony_attributes.rst index fa149a074..8efe9a411 100644 --- a/docs/symfony_attributes.rst +++ b/docs/symfony_attributes.rst @@ -8,10 +8,6 @@ MapQueryString Using the `Symfony MapQueryString`_ attribute allows NelmioApiDocBundle to automatically generate your query parameter documentation for your endpoint from your object. -.. versionadded:: 6.3 - - The :class:`Symfony\\Component\\HttpKernel\\Attribute\\MapQueryString` attribute was introduced in Symfony 6.3. - Modify generated documentation ~~~~~~~ @@ -35,11 +31,6 @@ MapQueryParameter Using the `Symfony MapQueryParameter`_ attribute allows NelmioApiDocBundle to automatically generate your query parameter documentation for your endpoint. -.. versionadded:: 6.3 - - The :class:`Symfony\\Component\\HttpKernel\\Attribute\\MapQueryParameter` attribute was introduced in Symfony 6.3. - - Modify generated documentation ~~~~~~~ @@ -59,11 +50,6 @@ MapRequestPayload Using the `Symfony MapRequestPayload`_ attribute allows NelmioApiDocBundle to automatically generate your request body documentation for your endpoint. -.. versionadded:: 6.3 - - The :class:`Symfony\\Component\\HttpKernel\\Attribute\\MapRequestPayload` attribute was introduced in Symfony 6.3. - - Modify generated documentation ~~~~~~~ diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index e6f2a0b4e..512479676 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -25,6 +25,11 @@ parameters: count: 1 path: src/Describer/ExternalDocDescriber.php + - + message: "#^Method Nelmio\\\\ApiDocBundle\\\\PropertyDescriber\\\\PropertyDescriberInterface\\:\\:describe\\(\\) invoked with 5 parameters, 2\\-3 required\\.$#" + count: 1 + path: src/ModelDescriber/ObjectModelDescriber.php + - message: "#^Method Nelmio\\\\ApiDocBundle\\\\PropertyDescriber\\\\ArrayPropertyDescriber\\:\\:supports\\(\\) has parameter \\$context with no value type specified in iterable type array\\.$#" count: 1 diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 0b2ec84fb..22ee2341f 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -25,6 +25,10 @@ public function getConfigTreeBuilder(): TreeBuilder $rootNode ->children() + ->booleanNode('type_info') + ->info('Use the symfony/type-info component for determining types.') + ->defaultFalse() + ->end() ->booleanNode('use_validation_groups') ->info('If true, `groups` passed to @Model annotations will be used to limit validation constraints') ->defaultFalse() diff --git a/src/DependencyInjection/NelmioApiDocExtension.php b/src/DependencyInjection/NelmioApiDocExtension.php index 259d014ce..d79d7c27e 100644 --- a/src/DependencyInjection/NelmioApiDocExtension.php +++ b/src/DependencyInjection/NelmioApiDocExtension.php @@ -165,6 +165,11 @@ public function load(array $configs, ContainerBuilder $container): void array_map(function ($area) { return new Reference(sprintf('nelmio_api_doc.generator.%s', $area)); }, array_keys($config['areas'])) )); + if (true === $config['type_info']) { + $container->getDefinition('nelmio_api_doc.model_describers.object') + ->setArgument(2, new Reference('nelmio_api_doc.type_describer.chain')); + } + $container->getDefinition('nelmio_api_doc.model_describers.object') ->setArgument(3, $config['media_types']); diff --git a/src/ModelDescriber/ObjectModelDescriber.php b/src/ModelDescriber/ObjectModelDescriber.php index 6e486c729..38f0ce7f0 100644 --- a/src/ModelDescriber/ObjectModelDescriber.php +++ b/src/ModelDescriber/ObjectModelDescriber.php @@ -18,13 +18,15 @@ use Nelmio\ApiDocBundle\ModelDescriber\Annotations\AnnotationsReader; use Nelmio\ApiDocBundle\OpenApiPhp\Util; use Nelmio\ApiDocBundle\PropertyDescriber\PropertyDescriberInterface; +use Nelmio\ApiDocBundle\TypeDescriber\TypeDescriberInterface; use OpenApi\Annotations as OA; use OpenApi\Generator; use Symfony\Component\PropertyInfo\PropertyInfoExtractorInterface; -use Symfony\Component\PropertyInfo\Type; +use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface; use Symfony\Component\Serializer\NameConverter\AdvancedNameConverterInterface; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; +use Symfony\Component\TypeInfo\Type; class ObjectModelDescriber implements ModelDescriberInterface, ModelRegistryAwareInterface { @@ -34,7 +36,7 @@ class ObjectModelDescriber implements ModelDescriberInterface, ModelRegistryAwar private PropertyInfoExtractorInterface $propertyInfo; private ?ClassMetadataFactoryInterface $classMetadataFactory; private ?Reader $doctrineReader; - /** @var PropertyDescriberInterface|PropertyDescriberInterface[] */ + /** @var PropertyDescriberInterface|PropertyDescriberInterface[]|TypeDescriberInterface */ private $propertyDescriber; /** @var string[] */ private array $mediaTypes; @@ -43,9 +45,9 @@ class ObjectModelDescriber implements ModelDescriberInterface, ModelRegistryAwar private bool $useValidationGroups; /** - * @param PropertyDescriberInterface|PropertyDescriberInterface[] $propertyDescribers - * @param (NameConverterInterface&AdvancedNameConverterInterface)|null $nameConverter - * @param string[] $mediaTypes + * @param PropertyDescriberInterface|PropertyDescriberInterface[]|TypeDescriberInterface $propertyDescribers + * @param (NameConverterInterface&AdvancedNameConverterInterface)|null $nameConverter + * @param string[] $mediaTypes */ public function __construct( PropertyInfoExtractorInterface $propertyInfo, @@ -59,7 +61,7 @@ public function __construct( if (is_iterable($propertyDescribers)) { trigger_deprecation('nelmio/api-doc-bundle', '4.17', 'Passing an array of PropertyDescriberInterface to %s() is deprecated. Pass a single PropertyDescriberInterface instead.', __METHOD__); } else { - if (!$propertyDescribers instanceof PropertyDescriberInterface) { + if (!$propertyDescribers instanceof PropertyDescriberInterface && !$propertyDescribers instanceof TypeDescriberInterface) { throw new \InvalidArgumentException(sprintf('Argument 3 passed to %s() must be an array of %s or a single %s.', __METHOD__, PropertyDescriberInterface::class, PropertyDescriberInterface::class)); } } @@ -151,8 +153,13 @@ public function describe(Model $model, OA\Schema $schema) continue; } - $types = $this->propertyInfo->getTypes($class, $propertyName); - if (null === $types || 0 === count($types)) { + if ($this->propertyDescriber instanceof TypeDescriberInterface) { + $types = $this->propertyInfo->getType($class, $propertyName); + } else { + $types = $this->propertyInfo->getTypes($class, $propertyName); + } + + if (null === $types) { throw new \LogicException(sprintf('The PropertyInfo component was not able to guess the type of %s::$%s. You may need to add a `@var` annotation or use `@OA\Property(type="")` to make its type explicit.', $class, $propertyName)); } @@ -191,9 +198,9 @@ private function camelize(string $string): string } /** - * @param Type[] $types + * @param LegacyType[]|Type $types */ - private function describeProperty(array $types, Model $model, OA\Schema $property, string $propertyName, OA\Schema $schema): void + private function describeProperty($types, Model $model, OA\Schema $property, string $propertyName, OA\Schema $schema): void { $propertyDescribers = is_iterable($this->propertyDescriber) ? $this->propertyDescriber : [$this->propertyDescriber]; @@ -202,13 +209,17 @@ private function describeProperty(array $types, Model $model, OA\Schema $propert $propertyDescriber->setModelRegistry($this->modelRegistry); } if ($propertyDescriber->supports($types, $model->getSerializationContext())) { - $propertyDescriber->describe($types, $property, $model->getGroups(), $schema, $model->getSerializationContext()); + if ($propertyDescriber instanceof PropertyDescriberInterface) { + $propertyDescriber->describe($types, $property, $model->getGroups(), $schema, $model->getSerializationContext()); + } else { + $propertyDescriber->describe($types, $property, $model->getSerializationContext()); + } return; } } - throw new \Exception(sprintf('Type "%s" is not supported in %s::$%s. You may use the `@OA\Property(type="")` annotation to specify it manually.', $types[0]->getBuiltinType(), $model->getType()->getClassName(), $propertyName)); + throw new \Exception(sprintf('Type "%s" is not supported in %s::$%s. You may need to use the `@OA\Property(type="")` annotation to specify it manually.', is_array($types) ? $types[0]->getBuiltinType() : $types, $model->getType()->getClassName(), $propertyName)); } /** @@ -243,7 +254,7 @@ private function markRequiredProperties(OA\Schema $schema): void public function supports(Model $model): bool { - return Type::BUILTIN_TYPE_OBJECT === $model->getType()->getBuiltinType() + return LegacyType::BUILTIN_TYPE_OBJECT === $model->getType()->getBuiltinType() && (class_exists($model->getType()->getClassName()) || interface_exists($model->getType()->getClassName())); } } diff --git a/src/PropertyDescriber/ObjectPropertyDescriber.php b/src/PropertyDescriber/ObjectPropertyDescriber.php index 5e28272a3..46ec33894 100644 --- a/src/PropertyDescriber/ObjectPropertyDescriber.php +++ b/src/PropertyDescriber/ObjectPropertyDescriber.php @@ -54,6 +54,13 @@ public function describe(array $types, OA\Schema $property, ?array $groups = nul $types[0]->getCollectionValueTypes()[0] ?? null, ); // ignore nullable field + if (null === $types[0]->getClassName()) { + $property->type = 'object'; + $property->additionalProperties = true; + + return; + } + if ($types[0]->isNullable()) { $weakContext = Util::createWeakContext($property->_context); $schemas = [new OA\Schema(['ref' => $this->modelRegistry->register(new Model($type, $groups, [], $context)), '_context' => $weakContext])]; diff --git a/src/PropertyDescriber/RequiredPropertyDescriber.php b/src/PropertyDescriber/RequiredPropertyDescriber.php index a51f31509..f2c29a853 100644 --- a/src/PropertyDescriber/RequiredPropertyDescriber.php +++ b/src/PropertyDescriber/RequiredPropertyDescriber.php @@ -11,6 +11,7 @@ namespace Nelmio\ApiDocBundle\PropertyDescriber; +use Nelmio\ApiDocBundle\ModelDescriber\ObjectModelDescriber; use OpenApi\Annotations as OA; use OpenApi\Generator; diff --git a/src/TypeDescriber/ArrayDescriber.php b/src/TypeDescriber/ArrayDescriber.php new file mode 100644 index 000000000..d1c3f70ef --- /dev/null +++ b/src/TypeDescriber/ArrayDescriber.php @@ -0,0 +1,50 @@ + + * + * @internal + */ +final class ArrayDescriber implements TypeDescriberInterface, TypeDescriberAwareInterface +{ + use TypeDescriberAwareTrait; + + public function describe(Type $type, Schema $schema, array $context = []): void + { + if (!$type->getCollectionKeyType() instanceof Type\UnionType) { + throw new \LogicException('This describer only supports '.CollectionType::class.' with '.Type\UnionType::class.' as key type.'); + } + + $arrayTypes = array_map( + fn (Type $keyType): Type => Type::array($type->getCollectionValueType(), $keyType), + $type->getCollectionKeyType()->getTypes() + ); + + $union = Type::union( + ...$arrayTypes + ); + + $this->describer->describe($union, $schema, $context); + } + + public function supports(Type $type, array $context = []): bool + { + return $type instanceof CollectionType + && $type->getCollectionKeyType() instanceof Type\UnionType; + } +} diff --git a/src/TypeDescriber/BoolDescriber.php b/src/TypeDescriber/BoolDescriber.php new file mode 100644 index 000000000..7bea6593b --- /dev/null +++ b/src/TypeDescriber/BoolDescriber.php @@ -0,0 +1,34 @@ + + * + * @internal + */ +final class BoolDescriber implements TypeDescriberInterface +{ + public function describe(Type $type, Schema $schema, array $context = []): void + { + $schema->type = 'boolean'; + } + + public function supports(Type $type, array $context = []): bool + { + return $type instanceof Type\BuiltinType + && $type->getTypeIdentifier()->isBool(); + } +} diff --git a/src/TypeDescriber/ChainDescriber.php b/src/TypeDescriber/ChainDescriber.php new file mode 100644 index 000000000..6386ce589 --- /dev/null +++ b/src/TypeDescriber/ChainDescriber.php @@ -0,0 +1,67 @@ + + * + * @internal + */ +final class ChainDescriber implements TypeDescriberInterface, ModelRegistryAwareInterface +{ + use ModelRegistryAwareTrait; + + /** @var iterable */ + private iterable $describers; + + /** + * @param iterable $describers + */ + public function __construct( + iterable $describers + ) { + $this->describers = $describers; + } + + public function describe(Type $type, Schema $schema, array $context = []): void + { + foreach ($this->describers as $describer) { + if ($describer instanceof ModelRegistryAwareInterface) { + $describer->setModelRegistry($this->modelRegistry); + } + + if ($describer instanceof TypeDescriberAwareInterface) { + $describer->setDescriber($this); + } + + if ($describer->supports($type, $context)) { + $describer->describe($type, $schema, $context); + } + } + } + + public function supports(Type $type, array $context = []): bool + { + foreach ($this->describers as $describer) { + if ($describer->supports($type, $context)) { + return true; + } + } + + return false; + } +} diff --git a/src/TypeDescriber/ClassDescriber.php b/src/TypeDescriber/ClassDescriber.php new file mode 100644 index 000000000..8cf085135 --- /dev/null +++ b/src/TypeDescriber/ClassDescriber.php @@ -0,0 +1,71 @@ + + * + * @internal + */ +final class ClassDescriber implements TypeDescriberInterface, ModelRegistryAwareInterface +{ + use ModelRegistryAwareTrait; + + public function describe(Type $type, Schema $schema, array $context = []): void + { + if (is_a($type->getClassName(), AbstractUid::class, true)) { + $schema->type = 'string'; + $schema->format = 'uuid'; + + return; + } + + if (is_a($type->getClassName(), \DateTimeInterface::class, true)) { + $schema->type = 'string'; + $schema->format = 'date-time'; + + return; + } + + // Ensure that the schema gets describe in oneOf for nullable objects + if (true === $schema->nullable) { + $weakContext = Util::createWeakContext($schema->_context); + if (Generator::UNDEFINED === $schema->oneOf) { + $schema->oneOf = []; + } + + $schema = $schema->oneOf[] = new Schema([ + '_context' => $weakContext + ]); + } + + $schema->ref = $this->modelRegistry->register( + new Model(new LegacyType('object', false, $type->getClassName()), null, null, $context) + ); + } + + public function supports(Type $type, array $context = []): bool + { + return $type instanceof ObjectType; + } +} diff --git a/src/TypeDescriber/DictionaryDescriber.php b/src/TypeDescriber/DictionaryDescriber.php new file mode 100644 index 000000000..aab49aa8c --- /dev/null +++ b/src/TypeDescriber/DictionaryDescriber.php @@ -0,0 +1,44 @@ + + * + * @internal + */ +final class DictionaryDescriber implements TypeDescriberInterface, TypeDescriberAwareInterface +{ + use TypeDescriberAwareTrait; + + public function describe(Type $type, Schema $schema, array $context = []): void + { + $schema->type = 'object'; + $additionalProperties = Util::getChild($schema, OA\AdditionalProperties::class); + + $this->describer->describe($type->getCollectionValueType(), $additionalProperties, $context); + } + + public function supports(Type $type, array $context = []): bool + { + return $type instanceof CollectionType + && $type->getCollectionKeyType() instanceof Type\BuiltinType + && TypeIdentifier::STRING === $type->getCollectionKeyType()->getTypeIdentifier(); + } +} diff --git a/src/TypeDescriber/FloatDescriber.php b/src/TypeDescriber/FloatDescriber.php new file mode 100644 index 000000000..8d044b8b2 --- /dev/null +++ b/src/TypeDescriber/FloatDescriber.php @@ -0,0 +1,36 @@ + + * + * @internal + */ +final class FloatDescriber implements TypeDescriberInterface +{ + public function describe(Type $type, Schema $schema, array $context = []): void + { + $schema->type = 'number'; + $schema->format = 'float'; + } + + public function supports(Type $type, array $context = []): bool + { + return $type instanceof Type\BuiltinType + && TypeIdentifier::FLOAT === $type->getTypeIdentifier(); + } +} diff --git a/src/TypeDescriber/IntegerDescriber.php b/src/TypeDescriber/IntegerDescriber.php new file mode 100644 index 000000000..038f5f488 --- /dev/null +++ b/src/TypeDescriber/IntegerDescriber.php @@ -0,0 +1,35 @@ + + * + * @internal + */ +final class IntegerDescriber implements TypeDescriberInterface +{ + public function describe(Type $type, Schema $schema, array $context = []): void + { + $schema->type = 'integer'; + } + + public function supports(Type $type, array $context = []): bool + { + return $type instanceof Type\BuiltinType + && TypeIdentifier::INT === $type->getTypeIdentifier(); + } +} diff --git a/src/TypeDescriber/IntersectionDescriber.php b/src/TypeDescriber/IntersectionDescriber.php new file mode 100644 index 000000000..9e15db7a1 --- /dev/null +++ b/src/TypeDescriber/IntersectionDescriber.php @@ -0,0 +1,49 @@ + + * + * @internal + */ +final class IntersectionDescriber implements TypeDescriberInterface, TypeDescriberAwareInterface +{ + use TypeDescriberAwareTrait; + + public function describe(Type $type, Schema $schema, array $context = []): void + { + $weakContext = Util::createWeakContext($schema->_context); + foreach ($type->getTypes() as $innerType) { + if (Generator::UNDEFINED === $schema->allOf) { + $schema->allOf = []; + } + + $schema->allOf[] = $childSchema = new Schema([ + '_context' => $weakContext + ]); + + $this->describer->describe($innerType, $childSchema, $context); + } + } + + public function supports(Type $type, array $context = []): bool + { + return $type instanceof IntersectionType; + } +} diff --git a/src/TypeDescriber/ListDescriber.php b/src/TypeDescriber/ListDescriber.php new file mode 100644 index 000000000..c9061b150 --- /dev/null +++ b/src/TypeDescriber/ListDescriber.php @@ -0,0 +1,44 @@ + + * + * @internal + */ +final class ListDescriber implements TypeDescriberInterface, TypeDescriberAwareInterface +{ + use TypeDescriberAwareTrait; + + public function describe(Type $type, Schema $schema, array $context = []): void + { + $schema->type = 'array'; + $item = Util::getChild($schema, OA\Items::class); + + $this->describer->describe($type->getCollectionValueType(), $item, $context); + } + + public function supports(Type $type, array $context = []): bool + { + return $type instanceof CollectionType + && $type->getCollectionKeyType() instanceof Type\BuiltinType + && TypeIdentifier::INT === $type->getCollectionKeyType()->getTypeIdentifier(); + } +} diff --git a/src/TypeDescriber/MixedDescriber.php b/src/TypeDescriber/MixedDescriber.php new file mode 100644 index 000000000..59a19472c --- /dev/null +++ b/src/TypeDescriber/MixedDescriber.php @@ -0,0 +1,36 @@ + + * + * @internal + */ +final class MixedDescriber implements TypeDescriberInterface +{ + public function describe(Type $type, Schema $schema, array $context = []): void + { + $schema->type = Generator::UNDEFINED; + } + + public function supports(Type $type, array $context = []): bool + { + return $type instanceof Type\BuiltinType + && TypeIdentifier::MIXED === $type->getTypeIdentifier(); + } +} diff --git a/src/TypeDescriber/NullableDescriber.php b/src/TypeDescriber/NullableDescriber.php new file mode 100644 index 000000000..c3de6ed0c --- /dev/null +++ b/src/TypeDescriber/NullableDescriber.php @@ -0,0 +1,33 @@ + + * + * @internal + */ +final class NullableDescriber implements TypeDescriberInterface +{ + public function describe(Type $type, Schema $schema, array $context = []): void + { + $schema->nullable = true; + } + + public function supports(Type $type, array $context = []): bool + { + return $type->isNullable(); + } +} diff --git a/src/TypeDescriber/ObjectDescriber.php b/src/TypeDescriber/ObjectDescriber.php new file mode 100644 index 000000000..2c71ff14c --- /dev/null +++ b/src/TypeDescriber/ObjectDescriber.php @@ -0,0 +1,41 @@ + + * + * @internal + */ +final class ObjectDescriber implements TypeDescriberInterface, ModelRegistryAwareInterface +{ + use ModelRegistryAwareTrait; + + public function describe(Type $type, Schema $schema, array $context = []): void + { + $schema->type = 'object'; + $schema->additionalProperties = true; + } + + public function supports(Type $type, array $context = []): bool + { + return $type instanceof BuiltinType + && TypeIdentifier::OBJECT === $type->getTypeIdentifier(); + } +} diff --git a/src/TypeDescriber/StringDescriber.php b/src/TypeDescriber/StringDescriber.php new file mode 100644 index 000000000..80a355f1f --- /dev/null +++ b/src/TypeDescriber/StringDescriber.php @@ -0,0 +1,35 @@ + + * + * @internal + */ +final class StringDescriber implements TypeDescriberInterface +{ + public function describe(Type $type, Schema $schema, array $context = []): void + { + $schema->type = 'string'; + } + + public function supports(Type $type, array $context = []): bool + { + return $type instanceof Type\BuiltinType + && TypeIdentifier::STRING === $type->getTypeIdentifier(); + } +} diff --git a/src/TypeDescriber/TypeDescriberAwareInterface.php b/src/TypeDescriber/TypeDescriberAwareInterface.php new file mode 100644 index 000000000..d0c394b84 --- /dev/null +++ b/src/TypeDescriber/TypeDescriberAwareInterface.php @@ -0,0 +1,17 @@ +describer = $describer; + } +} diff --git a/src/TypeDescriber/TypeDescriberInterface.php b/src/TypeDescriber/TypeDescriberInterface.php new file mode 100644 index 000000000..80df2b7cc --- /dev/null +++ b/src/TypeDescriber/TypeDescriberInterface.php @@ -0,0 +1,33 @@ + $context Context options for describing the property + */ + public function describe(Type $type, Schema $schema, array $context = []): void; + + /** + * @param T $type + * @param array $context Context options for describing the property + */ + public function supports(Type $type, array $context = []): bool; +} diff --git a/src/TypeDescriber/UnionDescriber.php b/src/TypeDescriber/UnionDescriber.php new file mode 100644 index 000000000..12abc0a81 --- /dev/null +++ b/src/TypeDescriber/UnionDescriber.php @@ -0,0 +1,61 @@ + + * + * @internal + */ +final class UnionDescriber implements TypeDescriberInterface, TypeDescriberAwareInterface +{ + use TypeDescriberAwareTrait; + + public function describe(Type $type, Schema $schema, array $context = []): void + { + $innerTypes = array_values(array_filter($type->getTypes(), function (Type $innerType) { + return !$innerType->isIdentifiedBy(TypeIdentifier::NULL); + })); + + // Ensure that union types of a single type are not described in oneOf + if (1 === count($innerTypes)) { + $this->describer->describe($innerTypes[0], $schema, $context); + + return; + } + + $weakContext = Util::createWeakContext($schema->_context); + foreach ($innerTypes as $innerType) { + if (Generator::UNDEFINED === $schema->oneOf) { + $schema->oneOf = []; + } + + $schema->oneOf[] = $childSchema = new Schema([ + '_context' => $weakContext + ]); + + $this->describer->describe($innerType, $childSchema, $context); + } + } + + public function supports(Type $type, array $context = []): bool + { + return $type instanceof UnionType; + } +} diff --git a/tests/Functional/ModelDescriber/Fixtures/ArrayOfInt.json b/tests/Functional/ModelDescriber/Fixtures/ArrayOfInt.json new file mode 100644 index 000000000..064e309e7 --- /dev/null +++ b/tests/Functional/ModelDescriber/Fixtures/ArrayOfInt.json @@ -0,0 +1,40 @@ +{ + "required": [ + "untypedArray", + "arrayOfIntegers", + "listOfIntegers", + "shortArrayOfIntegers" + ], + "properties": { + "untypedArray": { + "type": "array", + "items": {} + }, + "arrayOfIntegers": { + "type": "array", + "items": { + "type": "integer" + } + }, + "listOfIntegers": { + "type": "array", + "items": { + "type": "integer" + } + }, + "shortArrayOfIntegers": { + "type": "array", + "items": { + "type": "integer" + } + }, + "shortArrayOfIntegersNullable": { + "type": "array", + "items": { + "type": "integer" + }, + "nullable": true + } + }, + "type": "object" +} \ No newline at end of file diff --git a/tests/Functional/ModelDescriber/Fixtures/ArrayOfInt.php b/tests/Functional/ModelDescriber/Fixtures/ArrayOfInt.php new file mode 100644 index 000000000..241cc420e --- /dev/null +++ b/tests/Functional/ModelDescriber/Fixtures/ArrayOfInt.php @@ -0,0 +1,38 @@ + + */ + public array $arrayOfIntegers; + + /** + * @var list + */ + public array $listOfIntegers; + + /** + * @var int[] + */ + public array $shortArrayOfIntegers; + + /** + * @var int[]|null + */ + public ?array $shortArrayOfIntegersNullable; +} diff --git a/tests/Functional/ModelDescriber/Fixtures/ArrayOfString.json b/tests/Functional/ModelDescriber/Fixtures/ArrayOfString.json new file mode 100644 index 000000000..5847ea992 --- /dev/null +++ b/tests/Functional/ModelDescriber/Fixtures/ArrayOfString.json @@ -0,0 +1,33 @@ +{ + "required": [ + "untypedArray", + "arrayOfStrings", + "listOfStrings", + "shortArrayOfStrings" + ], + "properties": { + "untypedArray": { + "type": "array", + "items": {} + }, + "arrayOfStrings": { + "type": "array", + "items": { + "type": "string" + } + }, + "listOfStrings": { + "type": "array", + "items": { + "type": "string" + } + }, + "shortArrayOfStrings": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "type": "object" +} \ No newline at end of file diff --git a/tests/Functional/ModelDescriber/Fixtures/ArrayOfString.php b/tests/Functional/ModelDescriber/Fixtures/ArrayOfString.php new file mode 100644 index 000000000..b9a7d50f4 --- /dev/null +++ b/tests/Functional/ModelDescriber/Fixtures/ArrayOfString.php @@ -0,0 +1,33 @@ + + */ + public array $arrayOfStrings; + + /** + * @var list + */ + public array $listOfStrings; + + /** + * @var string[] + */ + public array $shortArrayOfStrings; +} diff --git a/tests/Functional/ModelDescriber/Fixtures/ClassWithIntersection.json b/tests/Functional/ModelDescriber/Fixtures/ClassWithIntersection.json new file mode 100644 index 000000000..a694bc444 --- /dev/null +++ b/tests/Functional/ModelDescriber/Fixtures/ClassWithIntersection.json @@ -0,0 +1,18 @@ +{ + "required": [ + "simpleClass" + ], + "properties": { + "simpleClass": { + "oneOf": [ + { + "$ref": "#/components/schemas/X" + }, + { + "$ref": "#/components/schemas/Y" + } + ] + } + }, + "type": "object" +} \ No newline at end of file diff --git a/tests/Functional/ModelDescriber/Fixtures/ClassWithIntersection.php b/tests/Functional/ModelDescriber/Fixtures/ClassWithIntersection.php new file mode 100644 index 000000000..3cf849af5 --- /dev/null +++ b/tests/Functional/ModelDescriber/Fixtures/ClassWithIntersection.php @@ -0,0 +1,25 @@ +|array + */ + public array $listOrDict; +} diff --git a/tests/Functional/ModelDescriber/Fixtures/DateTimeClass.json b/tests/Functional/ModelDescriber/Fixtures/DateTimeClass.json new file mode 100644 index 000000000..e633cef2b --- /dev/null +++ b/tests/Functional/ModelDescriber/Fixtures/DateTimeClass.json @@ -0,0 +1,27 @@ +{ + "required": [ + "dateTime", + "dateTimeImmutable", + "dateTimeInterface" + ], + "properties": { + "dateTime": { + "type": "string", + "format": "date-time" + }, + "dateTimeImmutable": { + "type": "string", + "format": "date-time" + }, + "dateTimeInterface": { + "type": "string", + "format": "date-time" + }, + "nullableDateTime": { + "type": "string", + "format": "date-time", + "nullable": true + } + }, + "type": "object" +} \ No newline at end of file diff --git a/tests/Functional/ModelDescriber/Fixtures/DateTimeClass.php b/tests/Functional/ModelDescriber/Fixtures/DateTimeClass.php new file mode 100644 index 000000000..3607972f3 --- /dev/null +++ b/tests/Functional/ModelDescriber/Fixtures/DateTimeClass.php @@ -0,0 +1,21 @@ + + */ + public array $mixedArray; +} diff --git a/tests/Functional/ModelDescriber/Fixtures/TypeInfo/ArrayOfInt.json b/tests/Functional/ModelDescriber/Fixtures/TypeInfo/ArrayOfInt.json new file mode 100644 index 000000000..e6e12c675 --- /dev/null +++ b/tests/Functional/ModelDescriber/Fixtures/TypeInfo/ArrayOfInt.json @@ -0,0 +1,62 @@ +{ + "required": [ + "untypedArray", + "arrayOfIntegers", + "listOfIntegers", + "shortArrayOfIntegers" + ], + "properties": { + "untypedArray": { + "oneOf": [ + { + "type": "array", + "items": { + "nullable": true + } + }, + { + "type": "object", + "additionalProperties": { + "nullable": true + } + } + ] + }, + "arrayOfIntegers": { + "oneOf": [ + { + "type": "array", + "items": { + "type": "integer" + } + }, + { + "type": "object", + "additionalProperties": { + "type": "integer" + } + } + ] + }, + "listOfIntegers": { + "type": "array", + "items": { + "type": "integer" + } + }, + "shortArrayOfIntegers": { + "type": "array", + "items": { + "type": "integer" + } + }, + "shortArrayOfIntegersNullable": { + "type": "array", + "items": { + "type": "integer" + }, + "nullable": true + } + }, + "type": "object" +} \ No newline at end of file diff --git a/tests/Functional/ModelDescriber/Fixtures/TypeInfo/ArrayOfString.json b/tests/Functional/ModelDescriber/Fixtures/TypeInfo/ArrayOfString.json new file mode 100644 index 000000000..771100795 --- /dev/null +++ b/tests/Functional/ModelDescriber/Fixtures/TypeInfo/ArrayOfString.json @@ -0,0 +1,55 @@ +{ + "required": [ + "untypedArray", + "arrayOfStrings", + "listOfStrings", + "shortArrayOfStrings" + ], + "properties": { + "untypedArray": { + "oneOf": [ + { + "type": "array", + "items": { + "nullable": true + } + }, + { + "type": "object", + "additionalProperties": { + "nullable": true + } + } + ] + }, + "arrayOfStrings": { + "oneOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + ] + }, + "listOfStrings": { + "type": "array", + "items": { + "type": "string" + } + }, + "shortArrayOfStrings": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "type": "object" +} \ No newline at end of file diff --git a/tests/Functional/ModelDescriber/Fixtures/TypeInfo/ClassWithIntersection.json b/tests/Functional/ModelDescriber/Fixtures/TypeInfo/ClassWithIntersection.json new file mode 100644 index 000000000..5e2661dcf --- /dev/null +++ b/tests/Functional/ModelDescriber/Fixtures/TypeInfo/ClassWithIntersection.json @@ -0,0 +1,18 @@ +{ + "required": [ + "simpleClass" + ], + "properties": { + "simpleClass": { + "allOf": [ + { + "$ref": "#/components/schemas/X" + }, + { + "$ref": "#/components/schemas/Y" + } + ] + } + }, + "type": "object" +} \ No newline at end of file diff --git a/tests/Functional/ModelDescriber/Fixtures/TypeInfo/ClassWithIntersectionNullable.json b/tests/Functional/ModelDescriber/Fixtures/TypeInfo/ClassWithIntersectionNullable.json new file mode 100644 index 000000000..f89ec46e1 --- /dev/null +++ b/tests/Functional/ModelDescriber/Fixtures/TypeInfo/ClassWithIntersectionNullable.json @@ -0,0 +1,16 @@ +{ + "properties": { + "simpleClass": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/X" + }, + { + "$ref": "#/components/schemas/Y" + } + ] + } + }, + "type": "object" +} \ No newline at end of file diff --git a/tests/Functional/ModelDescriber/Fixtures/TypeInfo/ClassWithIntersectionNullable.php b/tests/Functional/ModelDescriber/Fixtures/TypeInfo/ClassWithIntersectionNullable.php new file mode 100644 index 000000000..485cfc3f2 --- /dev/null +++ b/tests/Functional/ModelDescriber/Fixtures/TypeInfo/ClassWithIntersectionNullable.php @@ -0,0 +1,25 @@ + '3.0.0']); + $openApi = new OpenApi(['_context' => $context]); + $this->modelDescriber = self::getContainer()->get('nelmio_api_doc.model_describers.object'); + + $modelRegistry = new ModelRegistry([$this->modelDescriber], $openApi); + + $this->modelDescriber->setModelRegistry($modelRegistry); + } + + /** + * @dataProvider provideFixtures + */ + public function testItDescribes(string $class, ?string $fixtureDir = null): void + { + $model = new Model(new LegacyType('object', false, $class)); + $schema = new OA\Schema([ + 'type' => 'object', + ]); + + $this->modelDescriber->describe($model, $schema); + + $reflect = new \ReflectionClass($class); + + if (!file_exists($fixtureDir ??= dirname($reflect->getFileName()).'/'.$reflect->getShortName().'.json')) { + file_put_contents($fixtureDir, $schema->toJson()); + } + + self::assertSame( + self::getFixture($fixtureDir), + $schema->toJson(), + ); + } + + public static function provideFixtures(): \Generator + { + yield [ + Fixtures\SimpleClass::class, + ]; + + yield [ + Fixtures\ArrayOfInt::class, + ]; + + yield [ + Fixtures\ArrayOfString::class, + ]; + + yield [ + Fixtures\ComplexArray::class + ]; + + yield [ + Fixtures\ScalarTypes::class + ]; + + yield [ + Fixtures\NullableScalar::class + ]; + + yield [ + Fixtures\ClassWithObject::class + ]; + + if (PHP_VERSION_ID >= 80100) { + yield [ + Fixtures\ClassWithIntersection::class + ]; + } + + yield [ + Fixtures\DateTimeClass::class + ]; + + yield [ + Fixtures\UuidClass::class + ]; + + if (version_compare(Kernel::VERSION, '6.4.0', '>=')) { + yield [ + Fixtures\UuidClass7And8::class + ]; + } + + yield [ + Fixtures\Refs::class + ]; + } + + private static function getFixture(string $fixture): string + { + if (!file_exists($fixture)) { + self::fail(sprintf('The fixture file "%s" does not exist.', $fixture)); + } + + $content = file_get_contents($fixture); + + if (false === $content) { + self::fail(sprintf('Failed to read the fixture file "%s".', $fixture)); + } + + return $content; + } +} diff --git a/tests/Functional/ModelDescriber/ObjectModelDescriberTypeInfoTest.php b/tests/Functional/ModelDescriber/ObjectModelDescriberTypeInfoTest.php new file mode 100644 index 000000000..57d7174a5 --- /dev/null +++ b/tests/Functional/ModelDescriber/ObjectModelDescriberTypeInfoTest.php @@ -0,0 +1,111 @@ +=')) { + self::markTestSkipped('TypeInfo component is only available in Symfony 7.2 and later'); + } + + parent::setUp(); + } + + public static function provideFixtures(): \Generator + { + /* + * Checks if there is a replacement json file for the fixture + * This can be done in cases where the TypeInfo components is able to provide a better schema + */ + foreach (parent::provideFixtures() as $fixture) { + $class = $fixture[0]; + + $reflect = new \ReflectionClass($class); + if (file_exists($fixtureDir = dirname($reflect->getFileName()).'/TypeInfo/'.$reflect->getShortName().'.json')) { + yield [ + $class, + $fixtureDir + ]; + + continue; + } + + yield $fixture; + } + + yield [ + Fixtures\TypeInfo\ArrayMixedKeys::class + ]; + + yield [ + Fixtures\TypeInfo\MixedTypes::class + ]; + + yield [ + Fixtures\TypeInfo\ClassWithIntersectionNullable::class + ]; + } + + /** + * @dataProvider provideInvalidTypes + */ + public function testInvalidType(object $class, string $expectedType, string $propertyName): void + { + $model = new Model(new LegacyType('object', false, get_class($class))); + $schema = new OA\Schema([ + 'type' => 'object', + ]); + + self::expectException(\Exception::class); + self::expectExceptionMessage(sprintf('Type "%s" is not supported in %s::%s. You may need to use the `@OA\Property(type="")` annotation to specify it manually.', $expectedType, get_class($class), $propertyName)); + + $this->modelDescriber->describe($model, $schema); + } + + public static function provideInvalidTypes(): \Generator + { + yield 'never' => [ + new class { + public function getNever(): never + { + throw new \Exception('This method should never be called'); + } + }, + 'never', + '$never', + ]; + + yield 'void' => [ + new class { + public function getVoid(): void + { + } + }, + 'void', + '$void', + ]; + } +} diff --git a/tests/Functional/TestKernel.php b/tests/Functional/TestKernel.php index 2e6d96b08..919d5c422 100644 --- a/tests/Functional/TestKernel.php +++ b/tests/Functional/TestKernel.php @@ -46,6 +46,7 @@ class TestKernel extends Kernel public const USE_FOSREST = 3; public const USE_VALIDATION_GROUPS = 8; public const USE_FORM_CSRF = 16; + public const USE_TYPE_INFO = 32; private int $flag; @@ -243,6 +244,7 @@ protected function configureContainer(ContainerBuilder $c, LoaderInterface $load // Filter routes $c->loadFromExtension('nelmio_api_doc', [ + 'type_info' => self::USE_TYPE_INFO === $this->flag, 'html_config' => [ 'assets_mode' => AssetsMode::BUNDLE, ], diff --git a/tests/TypeDescriber/ArrayDescriberTest.php b/tests/TypeDescriber/ArrayDescriberTest.php new file mode 100644 index 000000000..2117b890b --- /dev/null +++ b/tests/TypeDescriber/ArrayDescriberTest.php @@ -0,0 +1,59 @@ +=')) { + self::markTestSkipped('TypeInfo component is only available in Symfony 7.2 and later'); + } + + $this->describer = new ArrayDescriber(); + } + + /** + * @dataProvider provideInvalidCollectionTypes + * + * @param CollectionType $type + */ + public function testDescribeHandlesInvalidKeyType($type): void + { + self::expectException(\LogicException::class); + self::expectExceptionMessage('This describer only supports '.CollectionType::class.' with '.UnionType::class.' as key type.'); + + $this->describer->describe($type, new Schema([])); + } + + public static function provideInvalidCollectionTypes(): \Generator + { + if (!version_compare(Kernel::VERSION, '7.2.0', '>=')) { + return yield [false]; + } + + yield [Type::array(Type::int(), Type::int())]; + yield [Type::array(Type::int(), Type::string())]; + yield [Type::list()]; + yield [Type::dict()]; + } +}