Skip to content

[MapRequestPayload] Allow usage of expressions for defining validation groups #58273

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 10 commits into
base: 7.4
Choose a base branch
from
Original file line number Diff line number Diff line change
Expand Up @@ -1774,6 +1774,7 @@ private function registerValidationConfiguration(array $config, ContainerBuilder
if (!class_exists(ExpressionLanguage::class)) {
$container->removeDefinition('validator.expression_language');
$container->removeDefinition('validator.expression_language_provider');
$container->removeDefinition('controller.expression_language');
} elseif (!class_exists(ExpressionLanguageProvider::class)) {
$container->removeDefinition('validator.expression_language_provider');
}
Expand Down
8 changes: 8 additions & 0 deletions src/Symfony/Bundle/FrameworkBundle/Resources/config/web.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\FrameworkBundle\Controller\ControllerResolver;
use Symfony\Bundle\FrameworkBundle\Controller\TemplateController;
use Symfony\Component\ExpressionLanguage\ExpressionLanguage;
use Symfony\Component\HttpKernel\Controller\ArgumentResolver;
use Symfony\Component\HttpKernel\Controller\ArgumentResolver\BackedEnumValueResolver;
use Symfony\Component\HttpKernel\Controller\ArgumentResolver\DateTimeValueResolver;
Expand Down Expand Up @@ -72,6 +73,7 @@
service('validator')->nullOnInvalid(),
service('translator')->nullOnInvalid(),
param('validator.translation_domain'),
service('controller.expression_language')->nullOnInvalid(),
])
->tag('controller.targeted_value_resolver', ['name' => RequestPayloadValueResolver::class])
->tag('kernel.event_subscriber')
Expand Down Expand Up @@ -145,5 +147,11 @@
->set('controller.cache_attribute_listener', CacheAttributeListener::class)
->tag('kernel.event_subscriber')

->set('controller.expression_language', ExpressionLanguage::class)
->args([service('cache.controller_expression_language')->nullOnInvalid()])

->set('cache.controller_expression_language')
->parent('cache.system')
->tag('cache.pool')
;
};
11 changes: 6 additions & 5 deletions src/Symfony/Component/HttpKernel/Attribute/MapQueryString.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

namespace Symfony\Component\HttpKernel\Attribute;

use Symfony\Component\ExpressionLanguage\Expression;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Controller\ArgumentResolver\RequestPayloadValueResolver;
use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata;
Expand All @@ -27,14 +28,14 @@ class MapQueryString extends ValueResolver
public ArgumentMetadata $metadata;

/**
* @param array<string, mixed> $serializationContext The serialization context to use when deserializing the query string
* @param string|GroupSequence|array<string>|null $validationGroups The validation groups to use when validating the query string mapping
* @param class-string $resolver The class name of the resolver to use
* @param int $validationFailedStatusCode The HTTP code to return if the validation fails
* @param array<string, mixed> $serializationContext The serialization context to use when deserializing the query string
* @param string|Expression|GroupSequence|array<string|Expression>|null $validationGroups The validation groups to use when validating the query string mapping
* @param class-string $resolver The class name of the resolver to use
* @param int $validationFailedStatusCode The HTTP code to return if the validation fails
*/
public function __construct(
public readonly array $serializationContext = [],
public readonly string|GroupSequence|array|null $validationGroups = null,
public readonly string|Expression|GroupSequence|array|null $validationGroups = null,
string $resolver = RequestPayloadValueResolver::class,
public readonly int $validationFailedStatusCode = Response::HTTP_NOT_FOUND,
public readonly ?string $key = null,
Expand Down
15 changes: 8 additions & 7 deletions src/Symfony/Component/HttpKernel/Attribute/MapRequestPayload.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

namespace Symfony\Component\HttpKernel\Attribute;

use Symfony\Component\ExpressionLanguage\Expression;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Controller\ArgumentResolver\RequestPayloadValueResolver;
use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata;
Expand All @@ -27,17 +28,17 @@ class MapRequestPayload extends ValueResolver
public ArgumentMetadata $metadata;

/**
* @param array<string>|string|null $acceptFormat The payload formats to accept (i.e. "json", "xml")
* @param array<string, mixed> $serializationContext The serialization context to use when deserializing the payload
* @param string|GroupSequence|array<string>|null $validationGroups The validation groups to use when validating the query string mapping
* @param class-string $resolver The class name of the resolver to use
* @param int $validationFailedStatusCode The HTTP code to return if the validation fails
* @param class-string|string|null $type The element type for array deserialization
* @param array<string>|string|null $acceptFormat The payload formats to accept (i.e. "json", "xml")
* @param array<string, mixed> $serializationContext The serialization context to use when deserializing the payload
* @param string|Expression|GroupSequence|array<string|Expression>|null $validationGroups The validation groups to use when validating the query string mapping
* @param class-string $resolver The class name of the resolver to use
* @param int $validationFailedStatusCode The HTTP code to return if the validation fails
* @param class-string|string|null $type The element type for array deserialization
*/
public function __construct(
public readonly array|string|null $acceptFormat = null,
public readonly array $serializationContext = [],
public readonly string|GroupSequence|array|null $validationGroups = null,
public readonly string|Expression|GroupSequence|array|null $validationGroups = null,
string $resolver = RequestPayloadValueResolver::class,
public readonly int $validationFailedStatusCode = Response::HTTP_UNPROCESSABLE_ENTITY,
public readonly ?string $type = null,
Expand Down
1 change: 1 addition & 0 deletions src/Symfony/Component/HttpKernel/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ CHANGELOG
* Add `$key` argument to `#[MapQueryString]` that allows using a specific key for argument resolving
* Support `Uid` in `#[MapQueryParameter]`
* Add `ServicesResetterInterface`, implemented by `ServicesResetter`
* Allow using Expression for `validationGroups` in `#[MapRequestPayload]` and `#[MapQueryString]`

7.2
---
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
namespace Symfony\Component\HttpKernel\Controller\ArgumentResolver;

use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\ExpressionLanguage\Expression;
use Symfony\Component\ExpressionLanguage\ExpressionLanguage;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Attribute\MapQueryString;
Expand All @@ -32,6 +34,7 @@
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\SerializerInterface;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Constraints\GroupSequence;
use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\ConstraintViolationList;
use Symfony\Component\Validator\Exception\ValidationFailedException;
Expand Down Expand Up @@ -64,6 +67,7 @@ public function __construct(
private readonly ?ValidatorInterface $validator = null,
private readonly ?TranslatorInterface $translator = null,
private string $translationDomain = 'validators',
private readonly ?ExpressionLanguage $expressionLanguage = null,
) {
}

Expand Down Expand Up @@ -147,7 +151,8 @@ public function onKernelControllerArguments(ControllerArgumentsEvent $event): vo
if (\is_array($payload) && !empty($constraints) && !$constraints instanceof Assert\All) {
$constraints = new Assert\All($constraints);
}
$violations->addAll($this->validator->validate($payload, $constraints, $argument->validationGroups ?? null));
$groups = $this->resolveValidationGroups($argument->validationGroups ?? null, $event);
$violations->addAll($this->validator->validate($payload, $constraints, $groups));
}

if (\count($violations)) {
Expand Down Expand Up @@ -234,4 +239,35 @@ private function mapUploadedFile(Request $request, ArgumentMetadata $argument, M
{
return $request->files->get($attribute->name ?? $argument->getName(), []);
}

/**
* @param Expression|string|GroupSequence|array<string|Expression>|null $validationGroups
*
* @return string|GroupSequence|array<string>|null
*/
private function resolveValidationGroups(Expression|string|GroupSequence|array|null $validationGroups, ControllerArgumentsEvent $event): string|GroupSequence|array|null
{
if ($validationGroups instanceof Expression) {
return $this->resolveExpression($validationGroups, $event);
}

if (!\is_array($validationGroups)) {
return $validationGroups;
}

return array_map(fn ($group) => $group instanceof Expression ? $this->resolveExpression($group, $event) : $group, $validationGroups);
}

private function resolveExpression(Expression $expression, ControllerArgumentsEvent $event): string
{
return $this->getExpressionLanguage()->evaluate($expression, [
'request' => $event->getRequest(),
'args' => $event->getNamedArguments(),
]);
}

private function getExpressionLanguage(): ExpressionLanguage
{
return $this->expressionLanguage ?? new ExpressionLanguage();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,20 @@
namespace Symfony\Component\HttpKernel\Tests\Controller\ArgumentResolver;

use PHPUnit\Framework\TestCase;
use Symfony\Component\ExpressionLanguage\Expression;
use Symfony\Component\ExpressionLanguage\ExpressionLanguage;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Attribute\MapQueryString;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
use Symfony\Component\HttpKernel\Attribute\ValueResolver;
use Symfony\Component\HttpKernel\Controller\ArgumentResolver\RequestPayloadValueResolver;
use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata;
use Symfony\Component\HttpKernel\Event\ControllerArgumentsEvent;
use Symfony\Component\HttpKernel\Event\ControllerEvent;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\HttpKernel\Exception\NearMissValueResolverException;
use Symfony\Component\HttpKernel\HttpKernelInterface;
use Symfony\Component\HttpKernel\Tests\Fixtures\Controller\BasicTypesController;
use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor;
use Symfony\Component\Serializer\Encoder\JsonEncoder;
use Symfony\Component\Serializer\Encoder\XmlEncoder;
Expand Down Expand Up @@ -960,6 +964,36 @@ public function testConfigKeyForQueryString()
$this->assertInstanceOf(QueryPayload::class, $event->getArguments()[0]);
$this->assertSame(1.0, $event->getArguments()[0]->page);
}

public function testExpressionAsValidationGroup()
{
$content = '{"price": 24}';

$serializer = new Serializer([new ObjectNormalizer()], ['json' => new JsonEncoder()]);
$validator = $this->createMock(ValidatorInterface::class);
$validator->expects($this->once())
->method('validate')
->with(new RequestPayload(24.0), null, 'foo');

$resolver = new RequestPayloadValueResolver($serializer, $validator, expressionLanguage: new ExpressionLanguage());

$argument = new ArgumentMetadata('payload', RequestPayload::class, false, false, null, false, [
MapRequestPayload::class => new MapRequestPayload(validationGroups: new Expression('args["foo"]')),
]);

$request = Request::create('/{foo}/{bar}/{baz}', 'POST', server: ['CONTENT_TYPE' => 'application/json'], content: $content);

$arguments = (array) $resolver->resolve($request, $argument);
array_unshift($arguments, 'foo', 15, 1.23);

$kernel = $this->createMock(HttpKernelInterface::class);

$controllerEvent = new ControllerEvent($kernel, [new BasicTypesController(), 'action'], $request, HttpKernelInterface::MAIN_REQUEST);

$event = new ControllerArgumentsEvent($kernel, $controllerEvent, $arguments, $request, HttpKernelInterface::MAIN_REQUEST);

$resolver->onKernelControllerArguments($event);
}
}

class RequestPayload
Expand Down
Loading