Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 1 addition & 5 deletions src/DependencyInjection/NelmioApiDocExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)
Expand Down
17 changes: 5 additions & 12 deletions src/OpenApiPhp/ModelRegister.php
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
Expand Down
92 changes: 1 addition & 91 deletions src/Processor/MapRequestPayloadProcessor.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
}
}
16 changes: 5 additions & 11 deletions src/RouteDescriber/FosRestDescriber.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -35,40 +45,98 @@
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())),

Check failure on line 133 in src/RouteDescriber/RouteArgumentDescriber/SymfonyMapRequestPayloadDescriber.php

View workflow job for this annotation

GitHub Actions / PHPStan

Parameter #1 $legacyType of static method Nelmio\ApiDocBundle\RouteDescriber\RouteArgumentDescriber\SymfonyMapRequestPayloadDescriber::toTypeInfoType() expects Symfony\Component\PropertyInfo\Type, array<Symfony\Component\PropertyInfo\Type> given.

Check failure on line 133 in src/RouteDescriber/RouteArgumentDescriber/SymfonyMapRequestPayloadDescriber.php

View workflow job for this annotation

GitHub Actions / PHPStan

Parameter #1 $legacyType of static method Nelmio\ApiDocBundle\RouteDescriber\RouteArgumentDescriber\SymfonyMapRequestPayloadDescriber::toTypeInfoType() expects Symfony\Component\PropertyInfo\Type, array<Symfony\Component\PropertyInfo\Type> given.
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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
16 changes: 16 additions & 0 deletions tests/Functional/Controller/MapRequestPayloadArray.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
) {
}
}
Loading
Loading