Skip to content

[HttpKernel] FromQuery, FromBody, FromRoute, etc. attributes #58709

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 6 commits into
base: 7.4
Choose a base branch
from
Open
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 @@ -18,7 +18,9 @@
use Doctrine\Persistence\ObjectManager;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
use Symfony\Component\ExpressionLanguage\ExpressionLanguage;
use Symfony\Component\HttpFoundation\ParameterBag;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Controller\RequestParameterValueResolverTrait;
use Symfony\Component\HttpKernel\Controller\ValueResolverInterface;
use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
Expand All @@ -31,16 +33,24 @@
*/
final class EntityValueResolver implements ValueResolverInterface
{
use RequestParameterValueResolverTrait;

public function __construct(
private ManagerRegistry $registry,
private ?ExpressionLanguage $expressionLanguage = null,
private MapEntity $defaults = new MapEntity(),
) {
}

public function resolve(Request $request, ArgumentMetadata $argument): array
protected function supports(ArgumentMetadata $argument): bool
{
return $argument->getAttributes(MapEntity::class, ArgumentMetadata::IS_INSTANCEOF)
|| ($argument->getType() && $this->getManager($this->defaults->objectManager, $argument->getType()));
}

protected function resolveValue(Request $request, ArgumentMetadata $argument, ParameterBag $valueBag): array
{
if (\is_object($request->attributes->get($argument->getName()))) {
if (\is_object($valueBag->get($argument->getName()))) {
return [];
}

Expand All @@ -56,13 +66,13 @@ public function resolve(Request $request, ArgumentMetadata $argument): array

$message = '';
if (null !== $options->expr) {
if (null === $object = $this->findViaExpression($manager, $request, $options)) {
if (null === $object = $this->findViaExpression($manager, $request, $options, $valueBag)) {
$message = \sprintf(' The expression "%s" returned null.', $options->expr);
}
// find by identifier?
} elseif (false === $object = $this->find($manager, $request, $options, $argument)) {
} elseif (false === $object = $this->find($manager, $request, $options, $argument, $valueBag)) {
// find by criteria
if (!$criteria = $this->getCriteria($request, $options, $manager, $argument)) {
if (!$criteria = $this->getCriteria($options, $manager, $argument, $valueBag)) {
return [];
}
try {
Expand Down Expand Up @@ -94,13 +104,13 @@ private function getManager(?string $name, string $class): ?ObjectManager
return $manager->getMetadataFactory()->isTransient($class) ? null : $manager;
}

private function find(ObjectManager $manager, Request $request, MapEntity $options, ArgumentMetadata $argument): false|object|null
private function find(ObjectManager $manager, Request $request, MapEntity $options, ArgumentMetadata $argument, ParameterBag $valueBag): false|object|null
{
if ($options->mapping || $options->exclude) {
return false;
}

$id = $this->getIdentifier($request, $options, $argument);
$id = $this->getIdentifier($options, $argument, $valueBag);
if (false === $id || null === $id) {
return $id;
}
Expand All @@ -122,7 +132,7 @@ private function find(ObjectManager $manager, Request $request, MapEntity $optio
}
}

private function getIdentifier(Request $request, MapEntity $options, ArgumentMetadata $argument): mixed
private function getIdentifier(MapEntity $options, ArgumentMetadata $argument, ParameterBag $valueBag): mixed
{
if (\is_array($options->id)) {
$id = [];
Expand All @@ -132,24 +142,24 @@ private function getIdentifier(Request $request, MapEntity $options, ArgumentMet
$field = \sprintf($field, $argument->getName());
}

$id[$field] = $request->attributes->get($field);
$id[$field] = $valueBag->get($field);
}

return $id;
}

if ($options->id) {
return $request->attributes->get($options->id) ?? ($options->stripNull ? false : null);
return $valueBag->get($options->id) ?? ($options->stripNull ? false : null);
}

$name = $argument->getName();

if ($request->attributes->has($name)) {
if (\is_array($id = $request->attributes->get($name))) {
if ($valueBag->has($name)) {
if (\is_array($id = $valueBag->get($name))) {
return false;
}

foreach ($request->attributes->get('_route_mapping') ?? [] as $parameter => $attribute) {
foreach ($valueBag->get('_route_mapping') ?? [] as $parameter => $attribute) {
if ($name === $attribute) {
$options->mapping = [$name => $parameter];

Expand All @@ -160,16 +170,16 @@ private function getIdentifier(Request $request, MapEntity $options, ArgumentMet
return $id ?? ($options->stripNull ? false : null);
}

if ($request->attributes->has('id')) {
return $request->attributes->get('id') ?? ($options->stripNull ? false : null);
if ($valueBag->has('id')) {
return $valueBag->get('id') ?? ($options->stripNull ? false : null);
}

return false;
}

private function getCriteria(Request $request, MapEntity $options, ObjectManager $manager, ArgumentMetadata $argument): array
private function getCriteria(MapEntity $options, ObjectManager $manager, ArgumentMetadata $argument, ParameterBag $valueBag): array
{
if (!($mapping = $options->mapping) && \is_array($criteria = $request->attributes->get($argument->getName()))) {
if (!($mapping = $options->mapping) && \is_array($criteria = $valueBag->get($argument->getName()))) {
foreach ($options->exclude as $exclude) {
unset($criteria[$exclude]);
}
Expand All @@ -181,7 +191,7 @@ private function getCriteria(Request $request, MapEntity $options, ObjectManager
return $criteria;
} elseif (null === $mapping) {
trigger_deprecation('symfony/doctrine-bridge', '7.1', 'Relying on auto-mapping for Doctrine entities is deprecated for argument $%s of "%s": declare the identifier using either the #[MapEntity] attribute or mapped route parameters.', $argument->getName(), method_exists($argument, 'getControllerName') ? $argument->getControllerName() : 'n/a');
$mapping = $request->attributes->keys();
$mapping = $valueBag->keys();
}

if ($mapping && array_is_list($mapping)) {
Expand All @@ -204,7 +214,7 @@ private function getCriteria(Request $request, MapEntity $options, ObjectManager
continue;
}

$criteria[$field] = $request->attributes->get($attribute);
$criteria[$field] = $valueBag->get($attribute);
}

if ($options->stripNull) {
Expand All @@ -214,14 +224,14 @@ private function getCriteria(Request $request, MapEntity $options, ObjectManager
return $criteria;
}

private function findViaExpression(ObjectManager $manager, Request $request, MapEntity $options): object|iterable|null
private function findViaExpression(ObjectManager $manager, Request $request, MapEntity $options, ParameterBag $valueBag): object|iterable|null
{
if (!$this->expressionLanguage) {
throw new \LogicException(\sprintf('You cannot use the "%s" if the ExpressionLanguage component is not available. Try running "composer require symfony/expression-language".', __CLASS__));
}

$repository = $manager->getRepository($options->class);
$variables = array_merge($request->attributes->all(), [
$variables = array_merge($valueBag->all(), [
'repository' => $repository,
'request' => $request,
]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
use Symfony\Component\ExpressionLanguage\ExpressionLanguage;
use Symfony\Component\ExpressionLanguage\SyntaxError;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Attribute\FromQuery;
use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;

Expand Down Expand Up @@ -297,6 +298,38 @@ public function testResolveWithRouteMapping()
$this->assertSame([$article], $resolver->resolve($request, $argument2));
}

public function testResolveFromValueBag()
{
$manager = $this->createMock(ObjectManager::class);
$registry = $this->createRegistry($manager);
$resolver = new EntityValueResolver($registry);

$request = new Request();
$request->query->set('conference_place', 'vienna');
$request->query->set('conference_year', '2024');

$argument1 = $this->createArgument('Conference', new MapEntity('Conference', mapping: ['conference_place' => 'place', 'conference_year' => 'year']), 'conference', false, [new FromQuery('*')]);

$manager->expects($this->never())
->method('getClassMetadata');

$conference = new \stdClass();

$repository = $this->createMock(ObjectRepository::class);
$repository->expects($this->any())
->method('findOneBy')
->willReturnCallback(static fn ($v) => match ($v) {
['place' => 'vienna', 'year' => '2024'] => $conference,
default => dd($v),
});

$manager->expects($this->any())
->method('getRepository')
->willReturn($repository);

$this->assertSame([$conference], $resolver->resolve($request, $argument1));
}

public function testExceptionWithExpressionIfNoLanguageAvailable()
{
$manager = $this->createMock(ObjectManager::class);
Expand Down Expand Up @@ -385,6 +418,58 @@ public function testExpressionMapsToArgument()
$this->assertSame([$object], $resolver->resolve($request, $argument));
}

public static function provideExpressionMapsToArgumentFromQueryExpressions(): array
{
return [
'with args in global scope' => ['repository.findByPlaceAndYear(place, year)'],
'with args in argument value scope' => ['repository.findByPlaceAndYear(conference.place, conference.year)'],
];
}

/** @dataProvider provideExpressionMapsToArgumentFromQueryExpressions */
public function testExpressionMapsToArgumentFromQuery(string $expr)
{
$manager = $this->createMock(ObjectManager::class);
$registry = $this->createRegistry($manager);
$language = $this->createMock(ExpressionLanguage::class);
$resolver = new EntityValueResolver($registry, $language);

$request = new Request();
$request->query->set('place', 'vienna');
$request->query->set('year', '2024');

$argument = $this->createArgument(
'stdClass',
new MapEntity(expr: $expr),
'conference',
false,
[new FromQuery('*')]
);

$repository = $this->createMock(ObjectRepository::class);
// find should not be attempted on this repository as a fallback
$repository->expects($this->never())
->method('find');

$manager->expects($this->once())
->method('getRepository')
->with(\stdClass::class)
->willReturn($repository);

$language->expects($this->once())
->method('evaluate')
->with($expr, [
'repository' => $repository,
'request' => $request,
'place' => 'vienna',
'year' => '2024',
'conference' => ['place' => 'vienna', 'year' => '2024'],
])
->willReturn($object = new \stdClass());

$this->assertSame([$object], $resolver->resolve($request, $argument));
}

public function testExpressionMapsToIterableArgument()
{
$manager = $this->createMock(ObjectManager::class);
Expand Down Expand Up @@ -474,9 +559,13 @@ public function testAlreadyResolved()
$this->assertSame([], $resolver->resolve($request, $argument));
}

private function createArgument(?string $class = null, ?MapEntity $entity = null, string $name = 'arg', bool $isNullable = false): ArgumentMetadata
private function createArgument(?string $class = null, ?MapEntity $entity = null, string $name = 'arg', bool $isNullable = false, array $attributes = []): ArgumentMetadata
{
return new ArgumentMetadata($name, $class ?? \stdClass::class, false, false, null, $isNullable, $entity ? [$entity] : []);
if ($entity) {
$attributes[] = $entity;
}

return new ArgumentMetadata($name, $class ?? \stdClass::class, false, false, null, $isNullable, $attributes);
}

private function createRegistry(?ObjectManager $manager = null): ManagerRegistry&MockObject
Expand Down
4 changes: 2 additions & 2 deletions src/Symfony/Bridge/Doctrine/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
"symfony/doctrine-messenger": "^6.4|^7.0",
"symfony/expression-language": "^6.4|^7.0",
"symfony/form": "^6.4.6|^7.0.6",
"symfony/http-kernel": "^6.4|^7.0",
"symfony/http-kernel": "^7.3",
"symfony/lock": "^6.4|^7.0",
"symfony/messenger": "^6.4|^7.0",
"symfony/property-access": "^6.4|^7.0",
Expand All @@ -58,7 +58,7 @@
"symfony/dependency-injection": "<6.4",
"symfony/form": "<6.4.6|>=7,<7.0.6",
"symfony/http-foundation": "<6.4",
"symfony/http-kernel": "<6.4",
"symfony/http-kernel": "<7.3",
"symfony/lock": "<6.4",
"symfony/messenger": "<6.4",
"symfony/property-info": "<6.4",
Expand Down
36 changes: 36 additions & 0 deletions src/Symfony/Component/HttpKernel/Attribute/FromBody.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\HttpKernel\Attribute;

/**
* Controller argument tag forcing ValueResolvers to use the value from request's body.
*
* @author Mike Kulakovsky <mike@kulakovs.ky>
*/
#[\Attribute(\Attribute::TARGET_PARAMETER)]
class FromBody extends FromRequestParameter
{
/**
* @param string|null $name The name of body parameter. Use "*" to collect all parameters. By default, the name of the argument in the controller will be used.
* @param int|array|null $filter the filter for `filter_var()` if int, or for `filter_var_array()` if array
* @param int|array|null $options The filter flag mask if int, or options array. If $filter is array, $options accepts only an array with "add_empty" key to be used as 3rd argument for filter_var_array()
* @param bool $throwOnFilterFailure whether to throw '400 Bad Request' on filtering failure or not, falling back to default (if any)
*/
public function __construct(
?string $name = null,
int|array|null $filter = null,
int|array|null $options = \FILTER_FLAG_EMPTY_STRING_NULL,
bool $throwOnFilterFailure = true,
) {
parent::__construct('request', $name, $filter, $options, $throwOnFilterFailure);
}
}
30 changes: 30 additions & 0 deletions src/Symfony/Component/HttpKernel/Attribute/FromFile.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\HttpKernel\Attribute;

/**
* Controller argument tag forcing ValueResolvers to use the value from request's files.
*
* @author Mike Kulakovsky <mike@kulakovs.ky>
*/
#[\Attribute(\Attribute::TARGET_PARAMETER)]
class FromFile extends FromRequestParameter
{
/**
* @param string|null $name The name of route attribute. Use "*" to collect all parameters. By default, the name of the argument in the controller will be used.
*/
public function __construct(
?string $name = null,
) {
parent::__construct('files', $name);
}
}
Loading