Skip to content

[HttpKernel] Add validation groups resolver option to RequestPayloadValueResolver #51233

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

Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ class UnusedTagsPass implements CompilerPassInterface
'container.service_subscriber',
'container.stack',
'controller.argument_value_resolver',
'controller.request_payload.validation_groups_resolver',
'controller.service_arguments',
'controller.targeted_value_resolver',
'data_collector',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
service('serializer'),
service('validator')->nullOnInvalid(),
service('translator')->nullOnInvalid(),
tagged_locator('controller.request_payload.validation_groups_resolver'),
])
->tag('controller.targeted_value_resolver', ['name' => RequestPayloadValueResolver::class])
->tag('kernel.event_subscriber')
Expand Down
4 changes: 4 additions & 0 deletions src/Symfony/Component/HttpKernel/Attribute/MapQueryString.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,17 @@
class MapQueryString extends ValueResolver
{
public ArgumentMetadata $metadata;
public readonly string|\Closure|null $validationGroupsResolver;

public function __construct(
public readonly array $serializationContext = [],
public readonly string|GroupSequence|array|null $validationGroups = null,
string $resolver = RequestPayloadValueResolver::class,
public readonly int $validationFailedStatusCode = Response::HTTP_NOT_FOUND,
string|callable $validationGroupsResolver = null,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about supporting only service ids?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about an array of validation groups to use instead of a resolver (less boilerplate for simple cases)?
We could also have both options.
Another way could be to have a specific interface to implement on the value object directly.

Copy link
Member

@yceruto yceruto Aug 3, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Determining the validation groups dynamically based on the state of the underlying object is already supported by
#[Assert\GroupSequenceProvider] https://symfony.com/doc/current/validation/sequence_provider.html#group-sequence-providers

I guess the use case the author wants to cover here is determining the groups based on external services through DI (which is not so common but still)

) {
parent::__construct($resolver);

$this->validationGroupsResolver = \is_callable($validationGroupsResolver) ? $validationGroupsResolver(...) : $validationGroupsResolver;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,18 @@
class MapRequestPayload extends ValueResolver
{
public ArgumentMetadata $metadata;
public readonly string|\Closure|null $validationGroupsResolver;

public function __construct(
public readonly array|string|null $acceptFormat = null,
public readonly array $serializationContext = [],
public readonly string|GroupSequence|array|null $validationGroups = null,
string $resolver = RequestPayloadValueResolver::class,
public readonly int $validationFailedStatusCode = Response::HTTP_UNPROCESSABLE_ENTITY,
string|callable $validationGroupsResolver = null,
) {
parent::__construct($resolver);

$this->validationGroupsResolver = \is_callable($validationGroupsResolver) ? $validationGroupsResolver(...) : $validationGroupsResolver;
}
}
1 change: 1 addition & 0 deletions src/Symfony/Component/HttpKernel/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ CHANGELOG
* Add optional `$className` parameter to `ControllerEvent::getAttributes()`
* Add native return types to `TraceableEventDispatcher` and to `MergeExtensionConfigurationPass`
* Add argument `$validationFailedStatusCode` to `#[MapQueryString]` and `#[MapRequestPayload]`
* Add argument `$validationGroupsResolver` to `#[MapQueryString]` and `#[MapRequestPayload]`

6.3
---
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

namespace Symfony\Component\HttpKernel\Controller\ArgumentResolver;

use Psr\Container\ContainerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
Expand All @@ -20,16 +21,19 @@
use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata;
use Symfony\Component\HttpKernel\Event\ControllerArgumentsEvent;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\HttpKernel\Exception\ResolverNotFoundException;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Serializer\Exception\NotEncodableValueException;
use Symfony\Component\Serializer\Exception\PartialDenormalizationException;
use Symfony\Component\Serializer\Exception\UnsupportedFormatException;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\SerializerInterface;
use Symfony\Component\Validator\Constraints\GroupSequence;
use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\ConstraintViolationList;
use Symfony\Component\Validator\Exception\ValidationFailedException;
use Symfony\Component\Validator\Validator\ValidatorInterface;
use Symfony\Contracts\Service\ServiceProviderInterface;
use Symfony\Contracts\Translation\TranslatorInterface;

/**
Expand Down Expand Up @@ -59,6 +63,7 @@ public function __construct(
private readonly SerializerInterface&DenormalizerInterface $serializer,
private readonly ?ValidatorInterface $validator = null,
private readonly ?TranslatorInterface $translator = null,
private readonly ?ContainerInterface $validationGroupsResolvers = null,
) {
}

Expand Down Expand Up @@ -120,7 +125,11 @@ public function onKernelControllerArguments(ControllerArgumentsEvent $event): vo
}

if (null !== $payload) {
$violations->addAll($this->validator->validate($payload, null, $argument->validationGroups ?? null));
$violations->addAll($this->validator->validate(
$payload,
null,
$argument->validationGroups ?? $this->resolveValidationGroups($argument->validationGroupsResolver, $payload, $request),
));
}

if (\count($violations)) {
Expand Down Expand Up @@ -155,6 +164,25 @@ public static function getSubscribedEvents(): array
];
}

private function resolveValidationGroups(string|\Closure|null $validationGroupsResolver, mixed $payload, Request $request): string|GroupSequence|array|null
{
if (null === $validationGroupsResolver) {
return null;
}

if ($validationGroupsResolver instanceof \Closure) {
return $validationGroupsResolver($payload, $request);
}

if (!$this->validationGroupsResolvers || !$this->validationGroupsResolvers->has($validationGroupsResolver)) {
throw new ResolverNotFoundException($validationGroupsResolver, $this->validationGroupsResolvers instanceof ServiceProviderInterface ? array_keys($this->validationGroupsResolvers->getProvidedServices()) : []);
} elseif (!\is_callable($validationGroupsResolver = $this->validationGroupsResolvers->get($resolverId = $validationGroupsResolver))) {
throw new \RuntimeException(sprintf('The service "%s" must be a callable.', $resolverId));
}

return $validationGroupsResolver($payload, $request);
}

private function mapQueryString(Request $request, string $type, MapQueryString $attribute): ?object
{
if (!$data = $request->query->all()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@
namespace Symfony\Component\HttpKernel\Tests\Controller\ArgumentResolver;

use PHPUnit\Framework\TestCase;
use Psr\Container\ContainerInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\ServiceLocator;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Attribute\MapQueryString;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
Expand All @@ -20,6 +23,7 @@
use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata;
use Symfony\Component\HttpKernel\Event\ControllerArgumentsEvent;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\HttpKernel\Exception\ResolverNotFoundException;
use Symfony\Component\HttpKernel\HttpKernelInterface;
use Symfony\Component\Serializer\Encoder\JsonEncoder;
use Symfony\Component\Serializer\Encoder\XmlEncoder;
Expand Down Expand Up @@ -522,7 +526,10 @@ public function testValidationGroupsPassed(string $method, ValueResolver $attrib

$serializer = new Serializer([new ObjectNormalizer()]);
$validator = (new ValidatorBuilder())->enableAnnotationMapping()->getValidator();
$resolver = new RequestPayloadValueResolver($serializer, $validator);
$locator = new ServiceLocator([
'validation_groups_resolver' => fn () => new ValidationGroupsResolver(),
]);
$resolver = new RequestPayloadValueResolver($serializer, $validator, null, $locator);

$request = Request::create('/', $method, $input);

Expand All @@ -548,7 +555,10 @@ public function testValidationGroupsNotPassed(string $method, ValueResolver $att

$serializer = new Serializer([new ObjectNormalizer()]);
$validator = (new ValidatorBuilder())->enableAnnotationMapping()->getValidator();
$resolver = new RequestPayloadValueResolver($serializer, $validator);
$locator = new ServiceLocator([
'validation_groups_resolver' => fn () => new ValidationGroupsResolver(),
]);
$resolver = new RequestPayloadValueResolver($serializer, $validator, null, $locator);

$argument = new ArgumentMetadata('valid', RequestPayload::class, false, false, null, false, [
$attribute::class => $attribute,
Expand Down Expand Up @@ -587,6 +597,16 @@ public static function provideValidationGroupsOnManyTypes(): iterable
new MapRequestPayload(validationGroups: new Assert\GroupSequence(['strict'])),
];

yield 'request payload with validation groups resolver' => [
'POST',
new MapRequestPayload(validationGroupsResolver: new ValidationGroupsResolver()),
];

yield 'request payload with validation groups resolver as service' => [
'POST',
new MapRequestPayload(validationGroupsResolver: 'validation_groups_resolver'),
];

yield 'query with validation group as string' => [
'GET',
new MapQueryString(validationGroups: 'strict'),
Expand All @@ -601,6 +621,90 @@ public static function provideValidationGroupsOnManyTypes(): iterable
'GET',
new MapQueryString(validationGroups: new Assert\GroupSequence(['strict'])),
];

yield 'query with validation groups resolver' => [
'GET',
new MapQueryString(validationGroupsResolver: new ValidationGroupsResolver()),
];

yield 'query with validation groups resolver as service' => [
'GET',
new MapQueryString(validationGroupsResolver: 'validation_groups_resolver'),
];
}

/**
* @dataProvider provideValidationGroupsResolverLocator
*/
public function testExceptionIsThrownIfValidationGroupsResolverIsNotFound(?ContainerInterface $locator, string $exceptionMessage)
{
$input = ['price' => '50', 'title' => 'A long title, so the validation passes'];

$serializer = new Serializer([new ObjectNormalizer()]);
$validator = (new ValidatorBuilder())->enableAnnotationMapping()->getValidator();
$resolver = new RequestPayloadValueResolver($serializer, $validator, null, $locator);

$request = Request::create('/', 'POST', $input);

$argument = new ArgumentMetadata('valid', RequestPayload::class, false, false, null, false, [
MapRequestPayload::class => new MapRequestPayload(validationGroupsResolver: 'validation_groups_resolver'),
]);

$kernel = $this->createMock(HttpKernelInterface::class);
$arguments = $resolver->resolve($request, $argument);
$event = new ControllerArgumentsEvent($kernel, function () {}, $arguments, $request, HttpKernelInterface::MAIN_REQUEST);

$this->expectException(ResolverNotFoundException::class);
$this->expectExceptionMessage($exceptionMessage);

$resolver->onKernelControllerArguments($event);
}

public static function provideValidationGroupsResolverLocator(): iterable
{
$message = 'You have requested a non-existent resolver "validation_groups_resolver".';

yield 'No locator' => [null, $message];

yield 'Empty locator' => [new ServiceLocator([]), $message];

yield 'Non-empty locator' => [new ServiceLocator([
'foo' => fn () => new \stdClass(),
'bar' => fn () => new \stdClass(),
]), $message.' Did you mean one of these: "foo", "bar"?'];

$container = new ContainerBuilder();
$container->register('foo', \stdClass::class);
$container->register('bar', \stdClass::class);

yield 'Container' => [$container, $message];
}

public function testExceptionIsThrownIfValidationGroupsResolverIsNotACallable()
{
$input = ['price' => '50', 'title' => 'A long title, so the validation passes'];

$serializer = new Serializer([new ObjectNormalizer()]);
$validator = (new ValidatorBuilder())->enableAnnotationMapping()->getValidator();
$locator = new ServiceLocator([
'validation_groups_resolver' => fn () => new \stdClass(),
]);
$resolver = new RequestPayloadValueResolver($serializer, $validator, null, $locator);

$request = Request::create('/', 'POST', $input);

$argument = new ArgumentMetadata('valid', RequestPayload::class, false, false, null, false, [
MapRequestPayload::class => new MapRequestPayload(validationGroupsResolver: 'validation_groups_resolver'),
]);

$kernel = $this->createMock(HttpKernelInterface::class);
$arguments = $resolver->resolve($request, $argument);
$event = new ControllerArgumentsEvent($kernel, function () {}, $arguments, $request, HttpKernelInterface::MAIN_REQUEST);

$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('The service "validation_groups_resolver" must be a callable.');

$resolver->onKernelControllerArguments($event);
}

public function testQueryValidationErrorCustomStatusCode()
Expand Down Expand Up @@ -687,3 +791,11 @@ public function __construct(public readonly float $page)
{
}
}

class ValidationGroupsResolver
{
public function __invoke(mixed $payload, Request $request)
{
return 'strict';
}
}