diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index 072d3975f..f2b6fb377 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -127,7 +127,7 @@ jobs: - name: Setup dependencies uses: ./.github/workflows/common/composer-install with: - symfony-version: "7.0.*" + symfony-version: "7.1.*" install-doctrine-annotations: false - name: Run PHPStan diff --git a/config/services.xml b/config/services.xml index be9c3cc56..df284c903 100644 --- a/config/services.xml +++ b/config/services.xml @@ -161,6 +161,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 6c228bec1..05b1ce46d 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -5,6 +5,11 @@ parameters: count: 1 path: src/Describer/ExternalDocDescriber.php + - + message: "#^Call to function method_exists\\(\\) with Symfony\\\\Component\\\\PropertyInfo\\\\PropertyInfoExtractorInterface and 'getType' will always evaluate to true\\.$#" + count: 1 + path: src/ModelDescriber/ObjectModelDescriber.php + - message: "#^Method Nelmio\\\\ApiDocBundle\\\\PropertyDescriber\\\\PropertyDescriberInterface\\:\\:describe\\(\\) invoked with 5 parameters, 2\\-3 required\\.$#" count: 1 diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 0b2ec84fb..3ff0d16f9 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -25,6 +25,10 @@ public function getConfigTreeBuilder(): TreeBuilder $rootNode ->children() + ->booleanNode('experimental_type_info') + ->info('Use the symfony/type-info component for determining types. This is experimental and could be changed at any time without prior notice.') + ->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 757703632..9e3f597e3 100644 --- a/src/DependencyInjection/NelmioApiDocExtension.php +++ b/src/DependencyInjection/NelmioApiDocExtension.php @@ -164,6 +164,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['experimental_type_info']) { + $container->getDefinition('nelmio_api_doc.model_describers.object') + ->setArgument(2, new Reference('nelmio_api_doc.schema_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 f511629b7..e135ee832 100644 --- a/src/ModelDescriber/ObjectModelDescriber.php +++ b/src/ModelDescriber/ObjectModelDescriber.php @@ -18,6 +18,7 @@ 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; @@ -34,7 +35,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 +44,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 +60,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)); } } @@ -175,12 +176,36 @@ public function describe(Model $model, OA\Schema $schema) continue; } - $types = $this->propertyInfo->getTypes($class, $propertyName); - if (null === $types || 0 === count($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)); + /* + * @experimental + */ + if ($this->propertyDescriber instanceof TypeDescriberInterface) { + if (false === method_exists($this->propertyInfo, 'getType')) { + throw new \RuntimeException('The PropertyInfo component is missing the "getType" method. Are you running on version 7.1?'); + } + + $type = $this->propertyInfo->getType($class, $propertyName); + if (null === $type) { + 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)); + } + + if ($this->propertyDescriber instanceof ModelRegistryAwareInterface) { + $this->propertyDescriber->setModelRegistry($this->modelRegistry); + } + + if (!$this->propertyDescriber->supports($type, $model->getSerializationContext())) { + throw new \Exception(sprintf('Type "%s" $model->getSerializationContext() not supported in %s::$%s. You may use the `@OA\Property(type="")` annotation to specify it manually.', $type->__toString(), $model->getType()->getClassName(), $propertyName)); + } + + $this->propertyDescriber->describe($type, $property, $model->getSerializationContext()); + } else { + $types = $this->propertyInfo->getTypes($class, $propertyName); + if (null === $types || 0 === count($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)); + } + + $this->describeProperty($types, $model, $property, $propertyName, $schema); } - - $this->describeProperty($types, $model, $property, $propertyName, $schema); } $this->markRequiredProperties($schema); diff --git a/src/PropertyDescriber/RequiredPropertyDescriber.php b/src/PropertyDescriber/RequiredPropertyDescriber.php index f4d88fc41..6500592b9 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/BoolDescriber.php b/src/TypeDescriber/BoolDescriber.php new file mode 100644 index 000000000..aac3471dd --- /dev/null +++ b/src/TypeDescriber/BoolDescriber.php @@ -0,0 +1,37 @@ + + * + * @experimental + * + * @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->isA(TypeIdentifier::BOOL); + } +} diff --git a/src/TypeDescriber/ChainDescriber.php b/src/TypeDescriber/ChainDescriber.php new file mode 100644 index 000000000..7cc5f6297 --- /dev/null +++ b/src/TypeDescriber/ChainDescriber.php @@ -0,0 +1,68 @@ + + * + * @experimental + * + * @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) { + /* BC layer for Symfony < 6.3 @see https://symfony.com/doc/6.3/service_container/tags.html#reference-tagged-services */ + if ($describer instanceof self) { + continue; + } + + 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 + { + return true; + } +} diff --git a/src/TypeDescriber/DictionaryDescriber.php b/src/TypeDescriber/DictionaryDescriber.php new file mode 100644 index 000000000..42c14337a --- /dev/null +++ b/src/TypeDescriber/DictionaryDescriber.php @@ -0,0 +1,45 @@ + + * + * @experimental + * + * @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()->isA(TypeIdentifier::STRING); + } +} diff --git a/src/TypeDescriber/FloatDescriber.php b/src/TypeDescriber/FloatDescriber.php new file mode 100644 index 000000000..2ae68b848 --- /dev/null +++ b/src/TypeDescriber/FloatDescriber.php @@ -0,0 +1,38 @@ + + * + * @experimental + * + * @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 + && $type->isA(TypeIdentifier::FLOAT); + } +} diff --git a/src/TypeDescriber/IntegerDescriber.php b/src/TypeDescriber/IntegerDescriber.php new file mode 100644 index 000000000..e94f81a4e --- /dev/null +++ b/src/TypeDescriber/IntegerDescriber.php @@ -0,0 +1,37 @@ + + * + * @experimental + * + * @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 + && $type->isA(TypeIdentifier::INT); + } +} diff --git a/src/TypeDescriber/IntersectionDescriber.php b/src/TypeDescriber/IntersectionDescriber.php new file mode 100644 index 000000000..24a01a475 --- /dev/null +++ b/src/TypeDescriber/IntersectionDescriber.php @@ -0,0 +1,63 @@ + + * + * @experimental + * + * @internal + */ +final class IntersectionDescriber 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->isA(TypeIdentifier::NULL); + })); + + // Ensure that non $ref schemas are not described in allOf + if (1 === count($innerTypes) && !$innerTypes[0] instanceof Type\ObjectType) { + $this->describer->describe($innerTypes[0], $schema, $context); + + return; + } + + $weakContext = Util::createWeakContext($schema->_context); + foreach ($innerTypes 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..830dc73e5 --- /dev/null +++ b/src/TypeDescriber/ListDescriber.php @@ -0,0 +1,45 @@ + + * + * @experimental + * + * @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()->isA(TypeIdentifier::INT); + } +} diff --git a/src/TypeDescriber/MixedDescriber.php b/src/TypeDescriber/MixedDescriber.php new file mode 100644 index 000000000..a0c993e63 --- /dev/null +++ b/src/TypeDescriber/MixedDescriber.php @@ -0,0 +1,38 @@ + + * + * @experimental + * + * @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 + && $type->isA(TypeIdentifier::MIXED); + } +} diff --git a/src/TypeDescriber/NullableDescriber.php b/src/TypeDescriber/NullableDescriber.php new file mode 100644 index 000000000..342113a1e --- /dev/null +++ b/src/TypeDescriber/NullableDescriber.php @@ -0,0 +1,37 @@ + + * + * @experimental + * + * @internal + */ +final class NullableDescriber implements TypeDescriberInterface, TypeDescriberAwareInterface +{ + use TypeDescriberAwareTrait; + + 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/ObjectClassDescriber.php b/src/TypeDescriber/ObjectClassDescriber.php new file mode 100644 index 000000000..7c38af9b8 --- /dev/null +++ b/src/TypeDescriber/ObjectClassDescriber.php @@ -0,0 +1,61 @@ + + * + * @experimental + * + * @internal + */ +final class ObjectClassDescriber 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; + } + + $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 + && $type->isA(TypeIdentifier::OBJECT); + } +} diff --git a/src/TypeDescriber/ObjectDescriber.php b/src/TypeDescriber/ObjectDescriber.php new file mode 100644 index 000000000..7ac0c967e --- /dev/null +++ b/src/TypeDescriber/ObjectDescriber.php @@ -0,0 +1,43 @@ + + * + * @experimental + * + * @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 + && $type->isA(TypeIdentifier::OBJECT); + } +} diff --git a/src/TypeDescriber/StringDescriber.php b/src/TypeDescriber/StringDescriber.php new file mode 100644 index 000000000..c84ece84a --- /dev/null +++ b/src/TypeDescriber/StringDescriber.php @@ -0,0 +1,37 @@ + + * + * @experimental + * + * @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 + && $type->isA(TypeIdentifier::STRING); + } +} diff --git a/src/TypeDescriber/TypeDescriberAwareInterface.php b/src/TypeDescriber/TypeDescriberAwareInterface.php new file mode 100644 index 000000000..94f581b43 --- /dev/null +++ b/src/TypeDescriber/TypeDescriberAwareInterface.php @@ -0,0 +1,22 @@ +describer = $describer; + } +} diff --git a/src/TypeDescriber/TypeDescriberInterface.php b/src/TypeDescriber/TypeDescriberInterface.php new file mode 100644 index 000000000..25c43c158 --- /dev/null +++ b/src/TypeDescriber/TypeDescriberInterface.php @@ -0,0 +1,37 @@ + $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..168821f67 --- /dev/null +++ b/src/TypeDescriber/UnionDescriber.php @@ -0,0 +1,63 @@ + + * + * @experimental + * + * @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->isA(TypeIdentifier::NULL); + })); + + // Ensure that non $ref schemas are not described in oneOf + if (1 === count($innerTypes) && !$innerTypes[0] instanceof Type\ObjectType) { + $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/RouteDescriber/RouteArgumentDescriber/SymfonyMapRequestPayloadDescriberTest.php b/tests/RouteDescriber/RouteArgumentDescriber/SymfonyMapRequestPayloadDescriberTest.php index 613a2ea18..362fd045f 100644 --- a/tests/RouteDescriber/RouteArgumentDescriber/SymfonyMapRequestPayloadDescriberTest.php +++ b/tests/RouteDescriber/RouteArgumentDescriber/SymfonyMapRequestPayloadDescriberTest.php @@ -55,7 +55,6 @@ public function testDescribeHandlesArrayParameterAndRegistersCorrectSchema(): vo null, false, [ - /* @phpstan-ignore-next-line can be removed with Symfony 7.1 integration */ new MapRequestPayload( null, [],