diff --git a/src/DependencyInjection/NelmioApiDocExtension.php b/src/DependencyInjection/NelmioApiDocExtension.php index 0642758f7..41e459da3 100644 --- a/src/DependencyInjection/NelmioApiDocExtension.php +++ b/src/DependencyInjection/NelmioApiDocExtension.php @@ -25,7 +25,6 @@ use Nelmio\ApiDocBundle\ModelDescriber\ModelDescriberInterface; use Nelmio\ApiDocBundle\OpenApiGenerator; use Nelmio\ApiDocBundle\Processor\MapQueryStringProcessor; -use Nelmio\ApiDocBundle\Processor\MapRequestPayloadProcessor; use Nelmio\ApiDocBundle\RouteDescriber\RouteArgumentDescriber; use Nelmio\ApiDocBundle\RouteDescriber\RouteArgumentDescriber\RouteArgumentDescriberInterface; use Nelmio\ApiDocBundle\RouteDescriber\RouteArgumentDescriber\SymfonyMapQueryParameterDescriber; @@ -225,12 +224,9 @@ public function load(array $configs, ContainerBuilder $container): void $container->register('nelmio_api_doc.route_argument_describer.map_request_payload', SymfonyMapRequestPayloadDescriber::class) ->setPublic(false) + ->setArgument(0, new Reference(true === $config['type_info'] ? 'nelmio_api_doc.type_describer.chain' : 'nelmio_api_doc.object_model.property_describer')) ->addTag('nelmio_api_doc.route_argument_describer', ['priority' => 0]); - $container->register('nelmio_api_doc.swagger.processor.map_request_payload', MapRequestPayloadProcessor::class) - ->setPublic(false) - ->addTag('nelmio_api_doc.swagger.processor', ['priority' => 0]); - if (class_exists(MapQueryParameter::class)) { $container->register('nelmio_api_doc.route_argument_describer.map_query_parameter', SymfonyMapQueryParameterDescriber::class) ->setPublic(false) diff --git a/src/OpenApiPhp/ModelRegister.php b/src/OpenApiPhp/ModelRegister.php index fb2776d01..97142397d 100644 --- a/src/OpenApiPhp/ModelRegister.php +++ b/src/OpenApiPhp/ModelRegister.php @@ -166,18 +166,11 @@ private function createContentForMediaType( OA\AbstractAnnotation $annotation, Analysis $analysis, ): void { - switch ($type) { - case 'json': - $modelAnnotation = new OA\JsonContent($properties); - - break; - case 'xml': - $modelAnnotation = new OA\XmlContent($properties); - - break; - default: - throw new \InvalidArgumentException(\sprintf("#[Model] attribute is not compatible with the media types '%s'. It must be one of 'json' or 'xml'.", implode(',', $this->mediaTypes))); - } + $modelAnnotation = match ($type) { + 'json' => new OA\JsonContent($properties), + 'xml' => new OA\XmlContent($properties), + default => throw new \InvalidArgumentException(\sprintf("#[Model] attribute is not compatible with the media types '%s'. It must be one of 'json' or 'xml'.", implode(',', $this->mediaTypes))), + }; $annotation->merge([$modelAnnotation]); $analysis->addAnnotation($modelAnnotation, $properties['_context']); diff --git a/src/Processor/MapRequestPayloadProcessor.php b/src/Processor/MapRequestPayloadProcessor.php index 95a6eea27..44dee3962 100644 --- a/src/Processor/MapRequestPayloadProcessor.php +++ b/src/Processor/MapRequestPayloadProcessor.php @@ -13,110 +13,20 @@ namespace Nelmio\ApiDocBundle\Processor; -use Nelmio\ApiDocBundle\OpenApiPhp\Util; use Nelmio\ApiDocBundle\RouteDescriber\RouteArgumentDescriber\SymfonyMapRequestPayloadDescriber; use OpenApi\Analysis; -use OpenApi\Annotations as OA; -use OpenApi\Generator; use Symfony\Component\HttpKernel\Attribute\MapRequestPayload; -use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata; /** * A processor that adds query parameters to operations that have a MapRequestPayload attribute. * A processor is used to ensure that a Model has been created. * * @see SymfonyMapRequestPayloadDescriber + * @deprecated */ final class MapRequestPayloadProcessor { public function __invoke(Analysis $analysis): void { - /** @var OA\Operation[] $operations */ - $operations = $analysis->getAnnotationsOfType(OA\Operation::class); - - foreach ($operations as $operation) { - if (!isset($operation->_context->{SymfonyMapRequestPayloadDescriber::CONTEXT_ARGUMENT_METADATA})) { - continue; - } - - $argumentMetaData = $operation->_context->{SymfonyMapRequestPayloadDescriber::CONTEXT_ARGUMENT_METADATA}; - if (!$argumentMetaData instanceof ArgumentMetadata) { - throw new \LogicException(\sprintf('MapRequestPayload ArgumentMetaData not found for operation "%s"', $operation->operationId)); - } - - /** @var MapRequestPayload $attribute */ - if (!$attribute = $argumentMetaData->getAttributes(MapRequestPayload::class, ArgumentMetadata::IS_INSTANCEOF)[0] ?? null) { - throw new \LogicException(\sprintf('Operation "%s" does not contain attribute of "%s', $operation->operationId, MapRequestPayload::class)); - } - - if (!isset($operation->_context->{SymfonyMapRequestPayloadDescriber::CONTEXT_MODEL_REF})) { - throw new \LogicException(\sprintf('MapRequestPayload Model reference not found for operation "%s"', $operation->operationId)); - } - $modelRef = $operation->_context->{SymfonyMapRequestPayloadDescriber::CONTEXT_MODEL_REF}; - - /** @var OA\RequestBody $requestBody */ - $requestBody = Util::getChild($operation, OA\RequestBody::class); - Util::modifyAnnotationValue($requestBody, 'required', !($argumentMetaData->hasDefaultValue() || $argumentMetaData->isNullable())); - - $formats = $attribute->acceptFormat; - if (!\is_array($formats)) { - $formats = [$attribute->acceptFormat ?? 'json']; - } - - foreach ($formats as $format) { - if (!Generator::isDefault($requestBody->content)) { - continue; - } - - $contentSchema = $this->getContentSchemaForType($requestBody, $format); - if ('array' === $argumentMetaData->getType()) { - $contentSchema->type = 'array'; - $contentSchema->items = new OA\Items( - [ - 'ref' => $modelRef, - '_context' => Util::createWeakContext($contentSchema->_context), - ] - ); - } else { - Util::modifyAnnotationValue($contentSchema, 'ref', $modelRef); - } - - if ($argumentMetaData->isNullable()) { - $contentSchema->nullable = true; - } - } - } - } - - private function getContentSchemaForType(OA\RequestBody $requestBody, string $type): OA\Schema - { - Util::modifyAnnotationValue($requestBody, 'content', []); - switch ($type) { - case 'json': - $contentType = 'application/json'; - - break; - case 'xml': - $contentType = 'application/xml'; - - break; - default: - throw new \InvalidArgumentException('Unsupported media type'); - } - - if (!isset($requestBody->content[$contentType])) { - $weakContext = Util::createWeakContext($requestBody->_context); - $requestBody->content[$contentType] = new OA\MediaType( - [ - 'mediaType' => $contentType, - '_context' => $weakContext, - ] - ); - } - - return Util::getChild( - $requestBody->content[$contentType], - OA\Schema::class - ); } } diff --git a/src/RouteDescriber/FosRestDescriber.php b/src/RouteDescriber/FosRestDescriber.php index b943b5a1b..775037c05 100644 --- a/src/RouteDescriber/FosRestDescriber.php +++ b/src/RouteDescriber/FosRestDescriber.php @@ -158,18 +158,12 @@ private function getEnum(mixed $requirements, \ReflectionMethod $reflectionMetho private function getContentSchemaForType(OA\RequestBody $requestBody, string $type): OA\Schema { $requestBody->content = Generator::UNDEFINED !== $requestBody->content ? $requestBody->content : []; - switch ($type) { - case 'json': - $contentType = 'application/json'; + $contentType = match ($type) { + 'json' => 'application/json', + 'xml' => 'application/xml', + default => throw new \InvalidArgumentException('Unsupported media type'), + }; - break; - case 'xml': - $contentType = 'application/xml'; - - break; - default: - throw new \InvalidArgumentException('Unsupported media type'); - } if (!isset($requestBody->content[$contentType])) { $weakContext = Util::createWeakContext($requestBody->_context); $requestBody->content[$contentType] = new OA\MediaType( diff --git a/src/RouteDescriber/RouteArgumentDescriber/SymfonyMapRequestPayloadDescriber.php b/src/RouteDescriber/RouteArgumentDescriber/SymfonyMapRequestPayloadDescriber.php index ffbbe9a59..f9cde59ff 100644 --- a/src/RouteDescriber/RouteArgumentDescriber/SymfonyMapRequestPayloadDescriber.php +++ b/src/RouteDescriber/RouteArgumentDescriber/SymfonyMapRequestPayloadDescriber.php @@ -15,18 +15,28 @@ use Nelmio\ApiDocBundle\Describer\ModelRegistryAwareInterface; use Nelmio\ApiDocBundle\Describer\ModelRegistryAwareTrait; -use Nelmio\ApiDocBundle\Model\Model; +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\HttpKernel\Attribute\MapRequestPayload; use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata; -use Symfony\Component\PropertyInfo\Type; -use Symfony\Component\Validator\Constraints\GroupSequence; +use Symfony\Component\PropertyInfo\Type as LegacyType; +use Symfony\Component\TypeInfo\Type; final class SymfonyMapRequestPayloadDescriber implements RouteArgumentDescriberInterface, ModelRegistryAwareInterface { use ModelRegistryAwareTrait; + public function __construct( + private PropertyDescriberInterface|TypeDescriberInterface $propertyDescriber, + ) { + } + + /** @deprecated */ public const CONTEXT_ARGUMENT_METADATA = 'nelmio_api_doc_bundle.argument_metadata.'.self::class; + /** @deprecated */ public const CONTEXT_MODEL_REF = 'nelmio_api_doc_bundle.model_ref.'.self::class; public function describe(ArgumentMetadata $argumentMetadata, OA\Operation $operation): void @@ -35,40 +45,98 @@ public function describe(ArgumentMetadata $argumentMetadata, OA\Operation $opera return; } - $typeClass = $argumentMetadata->getType(); + if (LegacyType::BUILTIN_TYPE_ARRAY === $argumentMetadata->getType() && property_exists($attribute, 'type')) { + $type = new LegacyType( + $argumentMetadata->getType(), + $argumentMetadata->isNullable(), + null, + true, + [new LegacyType(LegacyType::BUILTIN_TYPE_INT)], + [new LegacyType( + class_exists($attribute->type) ? LegacyType::BUILTIN_TYPE_OBJECT : $attribute->type, + false, + class_exists($attribute->type) ? $attribute->type : null, + )] + ); + } else { + $type = new LegacyType( + LegacyType::BUILTIN_TYPE_OBJECT, + $argumentMetadata->isNullable(), + $argumentMetadata->getType(), + ); + } + + if ($this->propertyDescriber instanceof ModelRegistryAwareInterface) { + $this->propertyDescriber->setModelRegistry($this->modelRegistry); + } + + /** @var OA\RequestBody $requestBody */ + $requestBody = Util::getChild($operation, OA\RequestBody::class); + Util::modifyAnnotationValue($requestBody, 'required', !($argumentMetadata->hasDefaultValue() || $argumentMetadata->isNullable())); - $reflectionAttribute = new \ReflectionClass(MapRequestPayload::class); - if (Type::BUILTIN_TYPE_ARRAY === $typeClass && $reflectionAttribute->hasProperty('type') && null !== $attribute->type) { - $typeClass = $attribute->type; + $formats = $attribute->acceptFormat; + if (!\is_array($formats)) { + $formats = [$attribute->acceptFormat ?? 'json']; } - $modelRef = $this->modelRegistry->register(new Model( - new Type(Type::BUILTIN_TYPE_OBJECT, false, $typeClass), - groups: $this->getGroups($attribute), - serializationContext: $attribute->serializationContext, - )); + foreach ($formats as $format) { + if (!Generator::isDefault($requestBody->content)) { + continue; + } + + $contentSchema = $this->getContentSchemaForType($requestBody, $format); + + if ($this->propertyDescriber instanceof TypeDescriberInterface) { + $types = self::toTypeInfoType($type); + } else { + $types = [$type]; + } - $operation->_context->{self::CONTEXT_ARGUMENT_METADATA} = $argumentMetadata; - $operation->_context->{self::CONTEXT_MODEL_REF} = $modelRef; + if ($this->propertyDescriber->supports($types, $attribute->serializationContext)) { + $this->propertyDescriber->describe($types, $contentSchema, $attribute->serializationContext); + + return; + } + } } - /** - * @return string[]|null - */ - private function getGroups(MapRequestPayload $attribute): ?array + private function getContentSchemaForType(OA\RequestBody $requestBody, string $type): OA\Schema { - if (\is_string($attribute->validationGroups)) { - return [$attribute->validationGroups]; - } + Util::modifyAnnotationValue($requestBody, 'content', []); + $contentType = match ($type) { + 'json' => 'application/json', + 'xml' => 'application/xml', + default => throw new \InvalidArgumentException('Unsupported media type'), + }; - if (\is_array($attribute->validationGroups)) { - return $attribute->validationGroups; - } + $mediaType = Util::getCollectionItem($requestBody, OA\MediaType::class, [ + 'mediaType' => $contentType, + ]); - if ($attribute->validationGroups instanceof GroupSequence) { - return $attribute->validationGroups->groups; - } + return Util::getChild( + $mediaType, + OA\Schema::class + ); + } + + /** @see \Symfony\Component\PropertyInfo\Util\LegacyTypeConverter::toTypeInfoType() */ + private static function toTypeInfoType(LegacyType $legacyType): Type + { + $typeInfoType = match ($legacyType->getBuiltinType()) { + LegacyType::BUILTIN_TYPE_BOOL => Type::bool(), + LegacyType::BUILTIN_TYPE_FALSE => Type::false(), + LegacyType::BUILTIN_TYPE_TRUE => Type::true(), + LegacyType::BUILTIN_TYPE_FLOAT => Type::float(), + LegacyType::BUILTIN_TYPE_INT => Type::int(), + LegacyType::BUILTIN_TYPE_STRING => Type::string(), + LegacyType::BUILTIN_TYPE_NULL => Type::null(), + LegacyType::BUILTIN_TYPE_ARRAY => Type::array(self::toTypeInfoType($legacyType->getCollectionValueTypes()), self::toTypeInfoType($legacyType->getCollectionKeyTypes())), + LegacyType::BUILTIN_TYPE_OBJECT => Type::object($legacyType->getClassName()), + default => throw new \InvalidArgumentException(\sprintf('"%s" is not a valid supported MapRequestPayload type.', $legacyType->getBuiltinType())), + }; - return null; + return LegacyType::BUILTIN_TYPE_NULL === $legacyType->getBuiltinType() || $legacyType->isNullable() + ? Type::nullable($typeInfoType) + : $typeInfoType; } } diff --git a/src/RouteDescriber/RouteArgumentDescriber/SymfonyMapUploadedFileDescriber.php b/src/RouteDescriber/RouteArgumentDescriber/SymfonyMapUploadedFileDescriber.php index fe46b73f0..e8e901e38 100644 --- a/src/RouteDescriber/RouteArgumentDescriber/SymfonyMapUploadedFileDescriber.php +++ b/src/RouteDescriber/RouteArgumentDescriber/SymfonyMapUploadedFileDescriber.php @@ -20,9 +20,6 @@ final class SymfonyMapUploadedFileDescriber implements RouteArgumentDescriberInterface { - public const CONTEXT_ARGUMENT_METADATA = 'nelmio_api_doc_bundle.argument_metadata.'.self::class; - public const CONTEXT_MODEL_REF = 'nelmio_api_doc_bundle.model_ref.'.self::class; - public function describe(ArgumentMetadata $argumentMetadata, OA\Operation $operation): void { if (!$attribute = $argumentMetadata->getAttributes(MapUploadedFile::class, ArgumentMetadata::IS_INSTANCEOF)[0] ?? null) { diff --git a/tests/Functional/Controller/MapRequestPayloadArray.php b/tests/Functional/Controller/MapRequestPayloadArray.php index a7ae43af6..b9d6b7b2b 100644 --- a/tests/Functional/Controller/MapRequestPayloadArray.php +++ b/tests/Functional/Controller/MapRequestPayloadArray.php @@ -33,4 +33,20 @@ public function createArticleFromMapRequestPayloadNullableArray( ?array $nullableArticles, ) { } + + #[Route('/article_map_request_payload_int_array', methods: ['POST'])] + #[OA\Response(response: '200', description: '')] + public function createArticleFromMapRequestPayloadIntArray( + #[MapRequestPayload(type: 'int')] + array $nullableArticles, + ) { + } + + #[Route('/article_map_request_payload_string_array', methods: ['POST'])] + #[OA\Response(response: '200', description: '')] + public function createArticleFromMapRequestPayloadStringArray( + #[MapRequestPayload(type: 'string')] + array $nullableArticles, + ) { + } } diff --git a/tests/Functional/Fixtures/MapRequestPayloadArray.json b/tests/Functional/Fixtures/MapRequestPayloadArray.json index ca2c41519..a578ffd93 100644 --- a/tests/Functional/Fixtures/MapRequestPayloadArray.json +++ b/tests/Functional/Fixtures/MapRequestPayloadArray.json @@ -51,6 +51,52 @@ } } } + }, + "/article_map_request_payload_int_array": { + "post": { + "operationId": "post_nelmio_apidoc_tests_functional_maprequestpayloadarray_createarticlefrommaprequestpayloadintarray", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "integer" + } + } + } + } + }, + "responses": { + "200": { + "description": "" + } + } + } + }, + "/article_map_request_payload_string_array": { + "post": { + "operationId": "post_nelmio_apidoc_tests_functional_maprequestpayloadarray_createarticlefrommaprequestpayloadstringarray", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + }, + "responses": { + "200": { + "description": "" + } + } + } } }, "components": { diff --git a/tests/Functional/Fixtures/MapRequestPayloadController.json b/tests/Functional/Fixtures/MapRequestPayloadController.json index 597150fed..af9ce77af 100644 --- a/tests/Functional/Fixtures/MapRequestPayloadController.json +++ b/tests/Functional/Fixtures/MapRequestPayloadController.json @@ -33,12 +33,12 @@ "content": { "application/json": { "schema": { + "nullable": true, "oneOf": [ { "$ref": "#/components/schemas/Article81" } - ], - "nullable": true + ] } } } @@ -59,7 +59,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EntityWithNullableSchemaSet" + "$ref": "#/components/schemas/Article81" } } } @@ -149,6 +149,8 @@ "SymfonyConstraintsWithValidationGroups": { "required": [ "property", + "propertyInDefaultGroup", + "propertyArray", "propertyNotNullOnSpecificGroup" ], "properties": { @@ -157,6 +159,17 @@ "maximum": 100, "minimum": 1 }, + "propertyInDefaultGroup": { + "type": "integer", + "maximum": 100, + "minimum": 1 + }, + "propertyArray": { + "type": "array", + "items": { + "type": "string" + } + }, "propertyNotNullOnSpecificGroup": { "type": "string" } diff --git a/tests/Functional/FunctionalTest.php b/tests/Functional/FunctionalTest.php index 9d73cd6de..a7b7c91df 100644 --- a/tests/Functional/FunctionalTest.php +++ b/tests/Functional/FunctionalTest.php @@ -114,11 +114,13 @@ public function testImplicitSwaggerAction(string $method): void $this->assertHasResponse('201', $operation); $response = $this->getOperationResponse($operation, '201'); self::assertEquals('Operation automatically detected', $response->description); + self::assertArrayHasKey('application/json', $response->content); self::assertEquals('#/components/schemas/User', $response->content['application/json']->schema->ref); self::assertInstanceOf(OAAnnotations\RequestBody::class, $operation->requestBody); $requestBody = $operation->requestBody; self::assertEquals('This is a request body', $requestBody->description); + self::assertArrayHasKey('application/json', $requestBody->content); self::assertEquals('array', $requestBody->content['application/json']->schema->type); self::assertEquals('#/components/schemas/User', $requestBody->content['application/json']->schema->items->ref); } diff --git a/tests/RouteDescriber/RouteArgumentDescriber/SymfonyMapRequestPayloadDescriberTest.php b/tests/RouteDescriber/RouteArgumentDescriber/SymfonyMapRequestPayloadDescriberTest.php deleted file mode 100644 index 3e90b759d..000000000 --- a/tests/RouteDescriber/RouteArgumentDescriber/SymfonyMapRequestPayloadDescriberTest.php +++ /dev/null @@ -1,76 +0,0 @@ -hasProperty('type')) { - self::markTestSkipped('Requires Symfony 7.1'); - } - - $registry = new ModelRegistry( - [$this->createMock(ModelDescriberInterface::class)], - $this->createMock(OpenApi::class), - ); - $describer = new SymfonyMapRequestPayloadDescriber(); - $describer->setModelRegistry($registry); - - $argumentData = new ArgumentMetadata( - 'someObjects', - Type::BUILTIN_TYPE_ARRAY, - false, - false, - null, - false, - [ - new MapRequestPayload( - null, - [], - null, - RequestPayloadValueResolver::class, - Response::HTTP_UNPROCESSABLE_ENTITY, - SomeObject::class - ), - ] - ); - - $operation = new Operation([]); - $operation->_context = new Context(); - - $describer->describe($argumentData, $operation); - - self::assertSame('#/components/schemas/SomeObject', $operation->_context->{SymfonyMapRequestPayloadDescriber::CONTEXT_MODEL_REF}); - } -} diff --git a/tests/RouteDescriber/RouteArgumentDescriber/fixture/SomeObject.php b/tests/RouteDescriber/RouteArgumentDescriber/fixture/SomeObject.php deleted file mode 100644 index 3a4f256fe..000000000 --- a/tests/RouteDescriber/RouteArgumentDescriber/fixture/SomeObject.php +++ /dev/null @@ -1,18 +0,0 @@ -