Skip to content
Draft
Show file tree
Hide file tree
Changes from 5 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 @@ -23,7 +23,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 @@ -210,12 +209,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