8000 Revert to RequestHeaderValueResolver for mapping DTO or individual He… · symfony/symfony@cced8c1 · GitHub
[go: up one dir, main page]

Skip to content

Commit cced8c1

Browse files
committed
Revert to RequestHeaderValueResolver for mapping DTO or individual Headers
1 parent 90f1015 commit cced8c1

File tree

7 files changed

+237
-93
lines changed

7 files changed

+237
-93
lines changed

src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -401,7 +401,7 @@ public function load(array $configs, ContainerBuilder $container)
401401
} else {
402402
$container->getDefinition('argument_resolver.request_payload')
403403
->setArguments([])
404-
->addError('You can neither use "#[MapRequestPayload]", "#[MapQueryString]" nor #[MapRequestHeader] since the Serializer component is not '
404+
->addError('You can neither use "#[MapRequestPayload]" nor "#[MapQueryString]" since the Serializer component is not '
405405
.(class_exists(Serializer::class) ? 'enabled. Try setting "framework.serializer.enabled" to true.' : 'installed. Try running "composer require symfony/serializer-pack".')
406406
)
407407
->addTag('container.error')

src/Symfony/Bundle/FrameworkBundle/Resources/config/web.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
use Symfony\Component\HttpKernel\Controller\ArgumentResolver\DefaultValueResolver;
1919
use Symfony\Component\HttpKernel\Controller\ArgumentResolver\QueryParameterValueResolver;
2020
use Symfony\Component\HttpKernel\Controller\ArgumentResolver\RequestAttributeValueResolver;
21+
use Symfony\Component\HttpKernel\Controller\ArgumentResolver\RequestHeaderValueResolver;
2122
use Symfony\Component\HttpKernel\Controller\ArgumentResolver\RequestPayloadValueResolver;
2223
use Symfony\Component\HttpKernel\Controller\ArgumentResolver\RequestValueResolver;
2324
use Symfony\Component\HttpKernel\Controller\ArgumentResolver\ServiceValueResolver;
@@ -97,6 +98,13 @@
9798
->set('argument_resolver.query_parameter_value_resolver', QueryParameterValueResolver::class)
9899
->tag('controller.targeted_value_resolver', ['name' => QueryParameterValueResolver::class])
99100

101+
->set('argument_resolver.header_value_resolver', RequestHeaderValueResolver::class)
102+
->args([
103+
service('serializer'),
104+
service('validator')->nullOnInvalid(),
105+
])
106+
->tag('controller.targeted_value_resolver', ['name' => RequestHeaderValueResolver::class])
107+
100108
->set('response_listener', ResponseListener::class)
101109
->args([
102110
param('kernel.charset'),

src/Symfony/Component/HttpKernel/Attribute/MapRequestHeader.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
namespace Symfony\Component\HttpKernel\Attribute;
1313

1414
use Symfony\Component\HttpFoundation\Response;
15-
use Symfony\Component\HttpKernel\Controller\ArgumentResolver\RequestPayloadValueResolver;
15+
use Symfony\Component\HttpKernel\Controller\ArgumentResolver\RequestHeaderValueResolver;
1616
use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata;
1717
use Symfony\Component\Validator\Constraints\GroupSequence;
1818

@@ -22,9 +22,10 @@ class MapRequestHeader extends ValueResolver
2222
public ArgumentMetadata $metadata;
2323

2424
public function __construct(
25+
public readonly string|array|null $name = null,
2526
public readonly array $serializationContext = [],
2627
public readonly string|GroupSequence|array|null $validationGroups = null,
27-
string $resolver = RequestPayloadValueResolver::class,
28+
string $resolver = RequestHeaderValueResolver::class,
2829
public readonly int $validationFailedStatusCode = Response::HTTP_UNPROCESSABLE_ENTITY,
2930
) {
3031
parent::__construct($resolver);
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\HttpKernel\Controller\ArgumentResolver;
13+
14+
use Symfony\Component\HttpFoundation\Request;
15+
use Symfony\Component\HttpKernel\Attribute\MapRequestHeader;
16+
use Symfony\Component\HttpKernel\Controller\ValueResolverInterface;
17+
use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata;
18+
use Symfony\Component\HttpKernel\Exception\HttpException;
19+
use Symfony\Component\Serializer\Exception\PartialDenormalizationException;
20+
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
21+
use Symfony\Component\Serializer\SerializerInterface;
22+
use Symfony\Component\Validator\ConstraintViolationList;
23+
use Symfony\Component\Validator\Exception\ValidationFailedException;
24+
use Symfony\Component\Validator\Validator\ValidatorInterface;
25+
26+
class RequestHeaderValueResolver implements ValueResolverInterface
27+
{
28+
/**
29+
* @see DenormalizerInterface::COLLECT_DENORMALIZATION_ERRORS
30+
*/
31+
private const CONTEXT_DESERIALIZE = [
32+
'collect_denormalization_errors' => true,
33+
];
34+
35+
public function __construct(
36+
private readonly SerializerInterface&DenormalizerInterface $serializer,
37+
private readonly ?ValidatorInterface $validator = null,
38+
) {
39+
}
40+
41+
public function resolve(Request $request, ArgumentMetadata $argument): iterable
42+
{
43+
if (!$attribute = $argument->getAttributesOfType(MapRequestHeader::class)[0] ?? null) {
44+
return [];
45+
}
46+
47+
$headers = [];
48+
$requestHeaders = $request->headers->all();
49+
50+
array_walk($requestHeaders, function ($value, $key) use (&$headers) {
51+
$headers[$key] = implode(',', $value);
52+
});
53+
54+
$type = $argument->getType();
55+
56+
if ('string' === $type || 'array' === $type) {
57+
$name = $attribute->name ?? $argument->getName();
58+
59+
if (!isset($headers[$name])) {
60+
return [null];
61+
}
62+
63+
if ('string' === $type) {
64+
return [$headers[$name]];
65+
}
66+
67+
return [explode(',', $headers[$name])];
68+
}
69+
70+
try {
71+
$payload = $this->serializer->denormalize($headers, $argument->getType(), null, self::CONTEXT_DESERIALIZE + $attribute->serializationContext);
72+
} catch (PartialDenormalizationException $e) {
73+
throw new HttpException($attribute->validationFailedStatusCode, implode("\n", array_map(static fn ($e) => $e->getMessage(), $e->getErrors())), $e);
74+
}
75+
76+
if ($this->validator) {
77+
$violations = new ConstraintViolationList();
78+
$violations->addAll($this->validator->validate($payload, null, $attribute->validationGroups));
79+
80+
if (\count($violations)) {
81+
throw new HttpException($attribute->validationFailedStatusCode, implode("\n", array_map(static fn ($e) => $e->getMessage(), iterator_to_array($violations))), new ValidationFailedException($payload, $violations));
82+
}
83+
}
84+
85+
if (null === $payload) {
86+
$payload = match (true) {
87+
$argument->hasDefaultValue() => $argument->getDefaultValue(),
88+
$argument->isNullable() => null,
89+
default => throw new HttpException($attribute->validationFailedStatusCode)
90+
};
91+
}
92+
93+
return [$payload];
94+
}
95+
}

src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/RequestPayloadValueResolver.php

Lines changed: 0 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515
use Symfony\Component\HttpFoundation\Request;
1616
use Symfony\Component\HttpFoundation\Response;
1717
use Symfony\Component\HttpKernel\Attribute\MapQueryString;
18-
use Symfony\Component\HttpKernel\Attribute\MapRequestHeader;
1918
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
2019
use Symfony\Component\HttpKernel\Controller\ValueResolverInterface;
2120
use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata;
@@ -67,7 +66,6 @@ public function resolve(Request $request, ArgumentMetadata $argument): iterable
6766
{
6867
$attribute = $argument->getAttributesOfType(MapQueryString::class, ArgumentMetadata::IS_INSTANCEOF)[0]
6968
?? $argument->getAttributesOfType(MapRequestPayload::class, ArgumentMetadata::IS_INSTANCEOF)[0]
70-
?? $argument->getAttributesOfType(MapRequestHeader::class, ArgumentMetadata::IS_INSTANCEOF)[0]
7169
?? null;
7270

7371
if (!$attribute) {
@@ -94,9 +92,6 @@ public function onKernelControllerArguments(ControllerArgumentsEvent $event): vo
9492
} elseif ($argument instanceof MapRequestPayload) {
9593
$payloadMapper = 'mapRequestPayload';
9694
$validationFailedCode = $argument->validationFailedStatusCode;
97-
} elseif ($argument instanceof MapRequestHeader) {
98-
$payloadMapper = 'mapRequestHeader';
99-
$validationFailedCode = $argument->validationFailedStatusCode;
10095
} else {
10196
continue;
10297
}
@@ -200,17 +195,4 @@ private function mapRequestPayload(Request $request, string $type, MapRequestPay
200195
throw new HttpException(Response::HTTP_BAD_REQUEST, sprintf('Request payload contains invalid "%s" data.', $format), $e);
201196
}
202197
}
203-
204-
private function mapRequestHeader(Request $request, string $type, MapRequestHeader $attribute): ?object
205-
{
206-
$headers = [];
207-
$requestHeaders = $request->headers->all();
208-
209-
array_walk($requestHeaders, function ($value, $key) use (&$headers) {
210-
$key = lcfirst(str_replace('-', '', ucwords($key, '-')));
211-
$headers[$key] = $value[0];
212-
});
213-
214-
return $this->serializer->denormalize($headers, $type, null, self::CONTEXT_DESERIALIZE + $attribute->serializationContext);
215-
}
216198
}
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\HttpKernel\Tests\Controller\ArgumentResolver;
13+
14+
use PHPUnit\Framework\TestCase;
15+
use Symfony\Component\HttpFoundation\Request;
16+
use Symfony\Component\HttpKernel\Attribute\MapRequestHeader;
17+
use Symfony\Component\HttpKernel\Controller\ArgumentResolver\RequestHeaderValueResolver;
18+
use Symfony\Component\HttpKernel\Controller\ValueResolverInterface;
19+
use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata;
20+
use Symfony\Component\HttpKernel\Exception\HttpException;
21+
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
22+
use Symfony\Component\Serializer\Serializer;
23+
use Symfony\Component\Validator\Constraints as Assert;
24+
use Symfony\Component\Validator\Exception\ValidationFailedException;
25+
use Symfony\Component\Validator\ValidatorBuilder;
26+
27+
class RequestHeaderValueResolverTest extends TestCase
28+
{
29+
private const HEADER_PARAMS = [
30+
'accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
31+
'accept-language' => 'en-us,en;q=0.5',
32+
'host' => 'localhost',
33+
'user-agent' => 'Symfony',
34+
];
35+
36+
private ValueResolverInterface $resolver;
37+
38+
protected function setUp(): void
39+
{
40+
$serializer = new Serializer([new ObjectNormalizer()]);
41+
$validator = (new ValidatorBuilder())->enableAnnotationMapping()->getValidator();
42+
$this->resolver = new RequestHeaderValueResolver($serializer, $validator);
43+
}
44+
45+
public function testWithStringType()
46+
{
47+
foreach (self::HEADER_PARAMS as $parameter => $value) {
48+
$metadata = new ArgumentMetadata('variableName', 'string', false, false, null, false, [
49+
MapRequestHeader::class => new MapRequestHeader($parameter),
50+
]);
51+
52+
$arguments = $this->resolver->resolve(Request::create('/'), $metadata);
53+
54+
self::assertEquals([$value], $arguments);
55+
}
56+
}
57+
58+
public function testWithArrayType()
59+
{
60+
foreach (self::HEADER_PARAMS as $parameter => $value) {
61+
$metadata = new ArgumentMetadata('variableName', 'array', false, false, null, false, [
62+
MapRequestHeader::class => new MapRequestHeader($parameter),
63+
]);
64+
65+
$arguments = $this->resolver->resolve(Request::create('/'), $metadata);
66+
67+
self::assertEquals([explode(',', $value)], $arguments);
68+
}
69+
}
70+
71+
public function testWithNoValue()
72+
{
73+
$metadata = new ArgumentMetadata('variableName', 'string', false, false, null, false, [
74+
MapRequestHeader::class => new MapRequestHeader(),
75+
]);
76+
77+
$arguments = $this->resolver->resolve(Request::create('/'), $metadata);
78+
79+
self::assertEquals([null], $arguments);
80+
}
81+
82+
public function testWithDtoAndErrorWithValidationGroups()
83+
{
84+
$request = Request::create('/');
85+
86+
$argument = new ArgumentMetadata('HeaderPayload', HeaderPayloadDto::class, false, false, null, false, [
87+
MapRequestHeader::class => new MapRequestHeader(validationGroups: ['strict']),
88+
]);
89+
90+
try {
91+
$this->resolver->resolve($request, $argument);
92+
$this->fail(sprintf('Expected "%s" to be thrown.', HttpException::class));
93+
} catch (HttpException $e) {
94+
$validationFailedException = $e->getPrevious();
95+
96+
$this->assertSame(422, $e->getStatusCode());
97+
$this->assertInstanceOf(ValidationFailedException::class, $validationFailedException);
98+
$this->assertSame('host', $validationFailedException->getViolations()[0]->getPropertyPath());
99+
$this->assertSame('This value should be equal to "symfony.com".', $validationFailedException->getViolations()[0]->getMessage());
100+
}
101+
}
102+
103+
public function testWithDtoAndDefaultValidationPassed()
104+
{
105+
$payload = new HeaderPayloadDto(
106+
'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
107+
'localhost'
108+
);
109+
110+
$request = Request::create('/');
111+
112+
$argument = new ArgumentMetadata('HeaderPayload', HeaderPayloadDto::class, false, false, null, false, [
113+
MapRequ 325D estHeader::class => new MapRequestHeader(),
114+
]);
115+
116+
$arguments = $this->resolver->resolve($request, $argument);
117+
118+
$this->assertEquals([$payload], $arguments);
119+
}
120+
}
121+
122+
class HeaderPayloadDto
123+
{
124+
public function __construct(
125+
public readonly string $accept,
126+
#[Assert\EqualTo('symfony.com', groups: ['strict'])]
127+
public readonly string $host,
128+
) {
129+
}
130+
}

0 commit comments

Comments
 (0)
0