From 2dfafdd6b838f9fe0a8cd5494a6385171d237226 Mon Sep 17 00:00:00 2001 From: Michael Babker Date: Tue, 28 Nov 2023 16:23:47 -0500 Subject: [PATCH] Create a controller argument resolver to replace the param converter --- Controller/Annotations/MapRequestBody.php | 79 +++++++ .../RequestBodyValueResolver.php | 219 ++++++++++++++++++ Request/RequestBodyParamConverter.php | 3 + 3 files changed, 301 insertions(+) create mode 100644 Controller/Annotations/MapRequestBody.php create mode 100644 Controller/ArgumentResolver/RequestBodyValueResolver.php diff --git a/Controller/Annotations/MapRequestBody.php b/Controller/Annotations/MapRequestBody.php new file mode 100644 index 000000000..984825c8a --- /dev/null +++ b/Controller/Annotations/MapRequestBody.php @@ -0,0 +1,79 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FOS\RestBundle\Controller\Annotations; + +use FOS\RestBundle\Controller\ArgumentResolver\RequestBodyValueResolver; +use Symfony\Component\HttpKernel\Attribute\ValueResolver; +use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata; + +if (class_exists(ValueResolver::class)) { + /** + * Compat value resolver for Symfony 6.3 and newer. + * + * @internal + */ + abstract class CompatMapRequestBody extends ValueResolver {} +} else { + /** + * Compat value resolver for Symfony 6.2 and older. + * + * @internal + */ + abstract class CompatMapRequestBody + { + public function __construct(string $resolver) + { + // No-op'd constructor because the ValueResolver does not exist on this Symfony version + } + } +} + +#[\Attribute(\Attribute::TARGET_PARAMETER)] +final class MapRequestBody extends CompatMapRequestBody +{ + /** + * @var ArgumentMetadata|null + */ + public $metadata = null; + + /** + * @var array + */ + public $deserializationContext; + + /** + * @var bool + */ + public $validate; + + /** + * @var array + */ + public $validator; + + /** + * @param array $deserializationContext + * @param array $validator + */ + public function __construct( + array $deserializationContext = [], + bool $validate = false, + array $validator = [], + string $resolver = RequestBodyValueResolver::class, + ) { + $this->deserializationContext = $deserializationContext; + $this->validate = $validate; + $this->validator = $validator; + + parent::__construct($resolver); + } +} diff --git a/Controller/ArgumentResolver/RequestBodyValueResolver.php b/Controller/ArgumentResolver/RequestBodyValueResolver.php new file mode 100644 index 000000000..1201e0ee1 --- /dev/null +++ b/Controller/ArgumentResolver/RequestBodyValueResolver.php @@ -0,0 +1,219 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FOS\RestBundle\Controller\ArgumentResolver; + +use FOS\RestBundle\Context\Context; +use FOS\RestBundle\Controller\Annotations\MapRequestBody; +use FOS\RestBundle\Serializer\Serializer; +use JMS\Serializer\Exception\Exception as JMSSerializerException; +use JMS\Serializer\Exception\UnsupportedFormatException; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Controller\ArgumentValueResolverInterface; +use Symfony\Component\HttpKernel\Controller\ValueResolverInterface; +use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata; +use Symfony\Component\HttpKernel\Event\ControllerArgumentsEvent; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; +use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException; +use Symfony\Component\HttpKernel\Exception\UnsupportedMediaTypeHttpException; +use Symfony\Component\HttpKernel\KernelEvents; +use Symfony\Component\OptionsResolver\OptionsResolver; +use Symfony\Component\Serializer\Exception\ExceptionInterface as SymfonySerializerException; +use Symfony\Component\Validator\Exception\ValidationFailedException; +use Symfony\Component\Validator\Validator\ValidatorInterface; + +if (interface_exists(ValueResolverInterface::class)) { + /** + * Compat value resolver for Symfony 6.2 and newer. + * + * @internal + */ + abstract class CompatRequestBodyValueResolver implements ValueResolverInterface {} +} else { + /** + * Compat value resolver for Symfony 6.1 and older. + * + * @internal + */ + abstract class CompatRequestBodyValueResolver implements ArgumentValueResolverInterface + { + public function supports(Request $request, ArgumentMetadata $argument): bool + { + $attribute = $argument->getAttributesOfType(MapRequestBody::class, ArgumentMetadata::IS_INSTANCEOF)[0] ?? null; + + return $attribute instanceof MapRequestBody; + } + } +} + +final class RequestBodyValueResolver extends CompatRequestBodyValueResolver implements EventSubscriberInterface +{ + /** + * @var Serializer + */ + private $serializer; + + /** + * @var array + */ + private $context = []; + + /** + * @var ValidatorInterface|null + */ + private $validator; + + /** + * @param list|null $groups + */ + public function __construct( + Serializer $serializer, + ?array $groups = null, + ?string $version = null, + ?ValidatorInterface $validator = null + ) { + $this->serializer = $serializer; + $this->validator = $validator; + + if (!empty($groups)) { + $this->context['groups'] = (array) $groups; + } + + if (!empty($version)) { + $this->context['version'] = $version; + } + } + + public static function getSubscribedEvents(): array + { + return [ + KernelEvents::CONTROLLER_ARGUMENTS => 'onKernelControllerArguments', + ]; + } + + public function resolve(Request $request, ArgumentMetadata $argument): iterable + { + $attribute = $argument->getAttributesOfType(MapRequestBody::class, ArgumentMetadata::IS_INSTANCEOF)[0] ?? null; + + if (!$attribute) { + return []; + } + + if ($argument->isVariadic()) { + throw new \LogicException(sprintf('Mapping variadic argument "$%s" is not supported.', $argument->getName())); + } + + $attribute->metadata = $argument; + + return [$attribute]; + } + + public function onKernelControllerArguments(ControllerArgumentsEvent $event): void + { + $arguments = $event->getArguments(); + + foreach ($arguments as $i => $argument) { + if (!$argument instanceof MapRequestBody) { + continue; + } + + if (!$type = $argument->metadata->getType()) { + throw new \LogicException(sprintf('Could not resolve the "$%s" controller argument: argument should be typed.', $argument->metadata->getName())); + } + + $request = $event->getRequest(); + + $format = method_exists(Request::class, 'getContentTypeFormat') ? $request->getContentTypeFormat() : $request->getContentType(); + + if (null === $format) { + throw new UnsupportedMediaTypeHttpException('Unsupported format.'); + } + + try { + $payload = $this->serializer->deserialize( + $request->getContent(), + $type, + $format, + $this->createContext(array_merge($this->context, $argument->deserializationContext)) + ); + } catch (UnsupportedFormatException $e) { + throw new UnsupportedMediaTypeHttpException($e->getMessage(), $e); + } catch (JMSSerializerException|SymfonySerializerException $e) { + throw new BadRequestHttpException($e->getMessage(), $e); + } + + if (null !== $payload && null !== $this->validator && $argument->validate) { + $validatorOptions = $this->getValidatorOptions($argument); + + $violations = $this->validator->validate($payload, null, $validatorOptions['groups']); + + if (\count($violations)) { + throw new UnprocessableEntityHttpException( + implode("\n", array_map(static function ($e) { return $e->getMessage(); }, iterator_to_array($violations))), + new ValidationFailedException($payload, $violations) + ); + } + } + + if (null === $payload) { + if ($argument->metadata->hasDefaultValue()) { + $payload = $argument->metadata->getDefaultValue(); + } elseif ($argument->metadata->isNullable()) { + $payload = null; + } else { + throw new UnprocessableEntityHttpException(); + } + } + + $arguments[$i] = $payload; + } + + $event->setArguments($arguments); + } + + private function createContext(array $options): Context + { + $context = new Context(); + + foreach ($options as $key => $value) { + if ('groups' === $key) { + $context->addGroups($options['groups']); + } elseif ('version' === $key) { + $context->setVersion($options['version']); + } elseif ('enableMaxDepth' === $key) { + if (true === $options['enableMaxDepth']) { + $context->enableMaxDepth(); + } elseif (false === $options['enableMaxDepth']) { + $context->disableMaxDepth(); + } + } elseif ('serializeNull' === $key) { + $context->setSerializeNull($options['serializeNull']); + } else { + $context->setAttribute($key, $value); + } + } + + return $context; + } + + private function getValidatorOptions(MapRequestBody $argument): array + { + $resolver = new OptionsResolver(); + $resolver->setDefaults([ + 'groups' => null, + 'traverse' => false, + 'deep' => false, + ]); + + return $resolver->resolve($argument->validator); + } +} diff --git a/Request/RequestBodyParamConverter.php b/Request/RequestBodyParamConverter.php index ae39855bc..8d02997c6 100644 --- a/Request/RequestBodyParamConverter.php +++ b/Request/RequestBodyParamConverter.php @@ -12,6 +12,7 @@ namespace FOS\RestBundle\Request; use FOS\RestBundle\Context\Context; +use FOS\RestBundle\Controller\ArgumentResolver\RequestBodyValueResolver; use FOS\RestBundle\Serializer\Serializer; use JMS\Serializer\Exception\Exception as JMSSerializerException; use JMS\Serializer\Exception\UnsupportedFormatException; @@ -26,6 +27,8 @@ /** * @author Tyler Stroud + * + * @deprecated use {@see RequestBodyValueResolver} instead */ final class RequestBodyParamConverter implements ParamConverterInterface {