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,
[],