diff --git a/Model/ModelRegistry.php b/Model/ModelRegistry.php index db346fb..7631d0b 100644 --- a/Model/ModelRegistry.php +++ b/Model/ModelRegistry.php @@ -100,7 +100,11 @@ final class ModelRegistry } if (null === $schema) { - throw new \LogicException(sprintf('Schema of type "%s" can\'t be generated, no describer supports it.', $this->typeToString($model->getType()))); + $errorMessage = sprintf('Schema of type "%s" can\'t be generated, no describer supports it.', $this->typeToString($model->getType())); + if (Type::BUILTIN_TYPE_OBJECT === $model->getType()->getBuiltinType() && !class_exists($className = $model->getType()->getClassName())) { + $errorMessage .= sprintf(' Class "\\%s" does not exist, did you forget a use statement, or typed it wrong?', $className); + } + throw new \LogicException($errorMessage); } } } @@ -174,7 +178,7 @@ final class ModelRegistry private function typeToString(Type $type): string { if (Type::BUILTIN_TYPE_OBJECT === $type->getBuiltinType()) { - return $type->getClassName(); + return '\\'.$type->getClassName(); } elseif ($type->isCollection()) { if (null !== $collectionType = $this->getCollectionValueType($type)) { return $this->typeToString($collectionType).'[]'; diff --git a/ModelDescriber/Annotations/AnnotationsReader.php b/ModelDescriber/Annotations/AnnotationsReader.php index 3fbcbfb..85f496d 100644 --- a/ModelDescriber/Annotations/AnnotationsReader.php +++ b/ModelDescriber/Annotations/AnnotationsReader.php @@ -14,6 +14,7 @@ namespace Nelmio\ApiDocBundle\ModelDescriber\Annotations; use Doctrine\Common\Annotations\Reader; use Nelmio\ApiDocBundle\Model\ModelRegistry; use OpenApi\Annotations as OA; +use OpenApi\Generator; /** * @internal @@ -44,10 +45,14 @@ class AnnotationsReader ); } - public function updateDefinition(\ReflectionClass $reflectionClass, OA\Schema $schema): void + public function updateDefinition(\ReflectionClass $reflectionClass, OA\Schema $schema): UpdateClassDefinitionResult { $this->openApiAnnotationsReader->updateSchema($reflectionClass, $schema); $this->symfonyConstraintAnnotationReader->setSchema($schema); + + return new UpdateClassDefinitionResult( + $this->shouldDescribeModelProperties($schema) + ); } public function getPropertyName($reflection, string $default): string @@ -61,4 +66,15 @@ class AnnotationsReader $this->phpDocReader->updateProperty($reflection, $property); $this->symfonyConstraintAnnotationReader->updateProperty($reflection, $property, $serializationGroups); } + + /** + * if an objects schema type and ref are undefined OR the object was manually + * defined as an object, then we're good to do the normal describe flow of + * class properties. + */ + private function shouldDescribeModelProperties(OA\Schema $schema): bool + { + return (Generator::UNDEFINED === $schema->type || 'object' === $schema->type) + && Generator::UNDEFINED === $schema->ref; + } } diff --git a/ModelDescriber/Annotations/UpdateClassDefinitionResult.php b/ModelDescriber/Annotations/UpdateClassDefinitionResult.php new file mode 100644 index 0000000..3a3945c --- /dev/null +++ b/ModelDescriber/Annotations/UpdateClassDefinitionResult.php @@ -0,0 +1,41 @@ +shouldDescribeModelProperties = $shouldDescribeModelProperties; + } + + public function shouldDescribeModelProperties(): bool + { + return $this->shouldDescribeModelProperties; + } +} diff --git a/ModelDescriber/EnumModelDescriber.php b/ModelDescriber/EnumModelDescriber.php new file mode 100644 index 0000000..41fba6e --- /dev/null +++ b/ModelDescriber/EnumModelDescriber.php @@ -0,0 +1,34 @@ +getType()->getClassName(); + + $enums = []; + foreach ($enumClass::cases() as $enumCase) { + $enums[] = $enumCase->value; + } + + $schema->type = is_subclass_of($enumClass, \IntBackedEnum::class) ? 'int' : 'string'; + $schema->enum = $enums; + } + + public function supports(Model $model): bool + { + if (!function_exists('enum_exists')) { + return false; + } + + return Type::BUILTIN_TYPE_OBJECT === $model->getType()->getBuiltinType() + && enum_exists($model->getType()->getClassName()) + && is_subclass_of($model->getType()->getClassName(), \BackedEnum::class); + } +} diff --git a/ModelDescriber/FormModelDescriber.php b/ModelDescriber/FormModelDescriber.php index 370f8a6..587b3a1 100644 --- a/ModelDescriber/FormModelDescriber.php +++ b/ModelDescriber/FormModelDescriber.php @@ -69,8 +69,6 @@ final class FormModelDescriber implements ModelDescriberInterface, ModelRegistry throw new \LogicException('You need to enable forms in your application to use a form as a model.'); } - $schema->type = 'object'; - $class = $model->getType()->getClassName(); $annotationsReader = new AnnotationsReader( @@ -79,7 +77,13 @@ final class FormModelDescriber implements ModelDescriberInterface, ModelRegistry $this->mediaTypes, $this->useValidationGroups ); - $annotationsReader->updateDefinition(new \ReflectionClass($class), $schema); + $classResult = $annotationsReader->updateDefinition(new \ReflectionClass($class), $schema); + + if (!$classResult->shouldDescribeModelProperties()) { + return; + } + + $schema->type = 'object'; $form = $this->formFactory->create($class, null, $model->getOptions() ?? []); $this->parseForm($schema, $form); diff --git a/ModelDescriber/JMSModelDescriber.php b/ModelDescriber/JMSModelDescriber.php index 9dc2c4e..fd59df7 100644 --- a/ModelDescriber/JMSModelDescriber.php +++ b/ModelDescriber/JMSModelDescriber.php @@ -80,14 +80,18 @@ class JMSModelDescriber implements ModelDescriberInterface, ModelRegistryAwareIn throw new \InvalidArgumentException(sprintf('No metadata found for class %s.', $className)); } - $schema->type = 'object'; $annotationsReader = new AnnotationsReader( $this->doctrineReader, $this->modelRegistry, $this->mediaTypes, $this->useValidationGroups ); - $annotationsReader->updateDefinition(new \ReflectionClass($className), $schema); + $classResult = $annotationsReader->updateDefinition(new \ReflectionClass($className), $schema); + + if (!$classResult->shouldDescribeModelProperties()) { + return; + } + $schema->type = 'object'; $isJmsV1 = null !== $this->namingStrategy; diff --git a/ModelDescriber/ObjectModelDescriber.php b/ModelDescriber/ObjectModelDescriber.php index 55c8115..925ea5c 100644 --- a/ModelDescriber/ObjectModelDescriber.php +++ b/ModelDescriber/ObjectModelDescriber.php @@ -62,8 +62,6 @@ class ObjectModelDescriber implements ModelDescriberInterface, ModelRegistryAwar public function describe(Model $model, OA\Schema $schema) { - $schema->type = 'object'; - $class = $model->getType()->getClassName(); $schema->_context->class = $class; @@ -79,7 +77,13 @@ class ObjectModelDescriber implements ModelDescriberInterface, ModelRegistryAwar $this->mediaTypes, $this->useValidationGroups ); - $annotationsReader->updateDefinition($reflClass, $schema); + $classResult = $annotationsReader->updateDefinition($reflClass, $schema); + + if (!$classResult->shouldDescribeModelProperties()) { + return; + } + + $schema->type = 'object'; $discriminatorMap = $this->getAnnotation($reflClass, DiscriminatorMap::class); if ($discriminatorMap && Generator::UNDEFINED === $schema->discriminator) { diff --git a/Resources/config/services.xml b/Resources/config/services.xml index bfc845c..b107302 100644 --- a/Resources/config/services.xml +++ b/Resources/config/services.xml @@ -80,6 +80,10 @@ + + + + diff --git a/Resources/doc/areas.rst b/Resources/doc/areas.rst index da2e43c..e1f0f08 100644 --- a/Resources/doc/areas.rst +++ b/Resources/doc/areas.rst @@ -32,6 +32,7 @@ You can define areas which will each generates a different documentation: store: # Includes routes with names containing 'store' name_patterns: [ store ] + Your main documentation is under the ``default`` area. It's the one shown when accessing ``/api/doc``. @@ -52,3 +53,38 @@ Then update your routing to be able to access your different documentations: # defaults: { _controller: nelmio_api_doc.controller.swagger } That's all! You can now access ``/api/doc/internal``, ``/api/doc/commercial`` and ``/api/doc/store``. + +Use annotations to filter documented routes in each area +-------------------------------------------------------- + +You can use the `@Areas` annotation inside your controllers to define your routes' areas. + +First, you need to define which areas will use the`@Areas` annotations to filter +the routes that should be documented: + +.. code-block:: yaml + + nelmio_api_doc: + areas: + default: + path_patterns: [ ^/api ] + internal: + with_annotations: true + +Then add the annotation before your controller or action:: + + use Nelmio\Annotations as Nelmio; + + /** + * @Nelmio\Areas({"internal"}) => All actions in this controller are documented under the 'internal' area + */ + class MyController + { + /** + * @Nelmio\Areas({"internal"}) => This action is documented under the 'internal' area + */ + public function index() + { + ... + } + } diff --git a/Resources/doc/commands.rst b/Resources/doc/commands.rst index 839a122..67ab295 100644 --- a/Resources/doc/commands.rst +++ b/Resources/doc/commands.rst @@ -5,7 +5,7 @@ A command is provided in order to dump the documentation in ``json``, ``yaml`` o .. code-block:: bash - $ php app/console api:doc:dump [--format="..."] + $ php app/console nelmio:apidoc:dump [--format="..."] The ``--format`` option allows to choose the format (default is: ``json``). @@ -14,20 +14,20 @@ without whitespace, use the ``--no-pretty`` option. .. code-block:: bash - $ php app/console api:doc:dump --format=json > json-pretty-formatted.json - $ php app/console api:doc:dump --format=json --no-pretty > json-no-pretty.json + $ php app/console nelmio:apidoc:dump --format=json > json-pretty-formatted.json + $ php app/console nelmio:apidoc:dump --format=json --no-pretty > json-no-pretty.json Every format can override API url. Useful if static documentation is not hosted on API url: .. code-block:: bash - $ php app/console api:doc:dump --format=yaml --server-url "http://example.com/api" > api.yaml + $ php app/console nelmio:apidoc:dump --format=yaml --server-url "http://example.com/api" > api.yaml For example to generate a static version of your documentation you can use: .. code-block:: bash - $ php app/console api:doc:dump --format=html > api.html + $ php app/console nelmio:apidoc:dump --format=html > api.html By default, the generated HTML will add the sandbox feature. If you want to generate a static version of your documentation without sandbox, @@ -40,6 +40,6 @@ or configure UI configuration, use the ``--html-config`` option. .. code-block:: bash - $ php app/console api:doc:dump --format=html --html-config '{"assets_mode":"offline","server_url":"https://example.com","swagger_ui_config":{"supportedSubmitMethods":[]}}' > api.html + $ php app/console nelmio:apidoc:dump --format=html --html-config '{"assets_mode":"offline","server_url":"https://example.com","swagger_ui_config":{"supportedSubmitMethods":[]}}' > api.html .. _`configure Swagger UI`: https://swagger.io/docs/open-source-tools/swagger-ui/usage/configuration/ diff --git a/Resources/doc/faq.rst b/Resources/doc/faq.rst index 6398a41..439dfb5 100644 --- a/Resources/doc/faq.rst +++ b/Resources/doc/faq.rst @@ -210,3 +210,30 @@ A: Use ``disable_default_routes`` config in your area. areas: default: disable_default_routes: true + +Overriding a Form or Plain PHP Object Schema Type +------------------------------------------------- + +Q: I'd like to define a PHP object or form with a type other any ``object``, how +do I do that? + +A: By using the ``@OA\Schema`` annotation or attribute with a ``type`` or ``ref``. +Note, however, that a ``type="object"`` will still read all a models properties. + +.. code-block:: php + + add('ignored', InputType::class, [ + 'required' => false, + ]); + } +} diff --git a/Tests/Functional/Form/FormWithRefType.php b/Tests/Functional/Form/FormWithRefType.php new file mode 100644 index 0000000..7904230 --- /dev/null +++ b/Tests/Functional/Form/FormWithRefType.php @@ -0,0 +1,31 @@ +add('ignored', InputType::class, [ + 'required' => false, + ]); + } +} diff --git a/Tests/Functional/FunctionalTest.php b/Tests/Functional/FunctionalTest.php index 8d53598..38afb94 100644 --- a/Tests/Functional/FunctionalTest.php +++ b/Tests/Functional/FunctionalTest.php @@ -624,4 +624,60 @@ class FunctionalTest extends WebTestCase $this->assertFalse($model->additionalProperties); } + + /** + * @requires PHP >= 8.1 + */ + public function testEnumSupport() + { + $model = $this->getModel('ArticleType81'); + + $this->assertSame('string', $model->type); + $this->assertCount(2, $model->enum); + } + + public function testEntitiesWithOverriddenSchemaTypeDoNotReadOtherProperties() + { + $model = $this->getModel('EntityWithAlternateType'); + + $this->assertSame('array', $model->type); + $this->assertSame('string', $model->items->type); + $this->assertSame(Generator::UNDEFINED, $model->properties); + } + + public function testEntitiesWithRefInSchemaDoNoReadOtherProperties() + { + $model = $this->getModel('EntityWithRef'); + + $this->assertSame(Generator::UNDEFINED, $model->type); + $this->assertSame('#/components/schemas/Test', $model->ref); + $this->assertSame(Generator::UNDEFINED, $model->properties); + } + + public function testEntitiesWithObjectTypeStillReadProperties() + { + $model = $this->getModel('EntityWithObjectType'); + + $this->assertSame('object', $model->type); + $this->assertCount(1, $model->properties); + $property = Util::getProperty($model, 'notIgnored'); + $this->assertSame('string', $property->type); + } + + public function testFormsWithOverriddenSchemaTypeDoNotReadOtherProperties() + { + $model = $this->getModel('FormWithAlternateSchemaType'); + + $this->assertSame('string', $model->type); + $this->assertSame(Generator::UNDEFINED, $model->properties); + } + + public function testFormWithRefInSchemaDoNoReadOtherProperties() + { + $model = $this->getModel('FormWithRefType'); + + $this->assertSame(Generator::UNDEFINED, $model->type); + $this->assertSame('#/components/schemas/Test', $model->ref); + $this->assertSame(Generator::UNDEFINED, $model->properties); + } } diff --git a/Tests/Model/ModelRegistryTest.php b/Tests/Model/ModelRegistryTest.php index ef7fd85..fdbbc36 100644 --- a/Tests/Model/ModelRegistryTest.php +++ b/Tests/Model/ModelRegistryTest.php @@ -234,7 +234,20 @@ class ModelRegistryTest extends TestCase { return [ [new Type(Type::BUILTIN_TYPE_ARRAY, false, null, true), 'mixed[]'], - [new Type(Type::BUILTIN_TYPE_OBJECT, false, self::class), self::class], + [new Type(Type::BUILTIN_TYPE_OBJECT, false, self::class), '\\'.self::class], ]; } + + public function testUnsupportedTypeExceptionWithNonExistentClass() + { + $className = DoesNotExist::class; + $type = new Type(Type::BUILTIN_TYPE_OBJECT, false, $className); + + $this->expectException('\LogicException'); + $this->expectExceptionMessage(sprintf('Schema of type "\%s" can\'t be generated, no describer supports it. Class "\Nelmio\ApiDocBundle\Tests\Model\DoesNotExist" does not exist, did you forget a use statement, or typed it wrong?', $className)); + + $registry = new ModelRegistry([], new OA\OpenApi([])); + $registry->register(new Model($type)); + $registry->registerSchemas(); + } } diff --git a/composer.json b/composer.json index d69570a..0d12f33 100644 --- a/composer.json +++ b/composer.json @@ -48,7 +48,7 @@ "symfony/twig-bundle": "^4.4|^5.2|^6.0", "symfony/validator": "^4.4|^5.2|^6.0", - "api-platform/core": "^2.4", + "api-platform/core": "^2.6.8", "friendsofsymfony/rest-bundle": "^2.8|^3.0", "willdurand/hateoas-bundle": "^1.0|^2.0", "jms/serializer-bundle": "^2.3|^3.0|^4.0",